feat: 添加完整的中文本地化支持和 DeepSeek 集成

- Web UI 完全汉化
  - 创建 i18n 基础设施 (i18n.ts, zh-CN.ts)
  - 汉化导航栏、侧边栏、标签页
  - 所有核心界面组件已翻译为中文

- DeepSeek API 完整支持
  - 添加配置示例 (docs/examples/deepseek.json)
  - 支持 deepseek-chat 和 deepseek-reasoner 模型
  - 编写详细的中文配置指南

- 文档汉化
  - 创建中文版 README (README.zh-CN.md)
  - 突出 OpenClaw CN 特性和优势
  - 添加 DeepSeek 使用指南 (docs/zh-CN/deepseek-guide.md)

- 构建验证通过
  - UI 成功构建,121 个模块转换完成
  - 所有文件正常工作
This commit is contained in:
Xu, Jingrong 2026-01-30 14:36:03 +08:00
parent 6af205a13a
commit fc955e85ad
8 changed files with 2509 additions and 400 deletions

171
README.zh-CN.md Normal file
View File

@ -0,0 +1,171 @@
# 🦞 OpenClaw CN — 个人 AI 助手 (中文增强版)
<p align="center">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500">
</picture>
</p>
**OpenClaw CN** 是 OpenClaw 的中文增强版本,为中国用户提供更好的使用体验,并集成了国内主流的通信渠道和大模型提供商。
## 🎯 特性增强
### 🇨🇳 全面中文化
- ✅ **Web UI 汉化**:完整的中文用户界面
- ✅ **中文文档**:安装指南、配置说明和使用教程
- ✅ **本地化体验**:针对中文用户优化的交互设计
### 🤖 新增大模型支持
- ✅ **DeepSeek**:完整支持 DeepSeek API
- `deepseek-chat` (V3.2 非思考模式)
- `deepseek-reasoner` (V3.2 思考模式)
- 完全兼容 OpenAI API 格式
- 极具竞争力的价格
### 💬 新增通信渠道
#### 已实现
- 🔵 **企业渠道**(基于官方 API
- 钉钉 (DingTalk)
- 飞书 (Feishu/Lark)
- 企业微信 (WeChat Work)
#### 计划中
- 🟡 **个人渠道**(基于 OneBot 协议)
- 微信(通过 OneBot 适配器如 Gewechat
- QQ通过 OneBot 适配器如 NapCatQQ
- 华为畅连(开发中)
> **注意**:个人微信和 QQ 需要运行独立的 OneBot 客户端。推荐方案:
> - **QQ**: [NapCatQQ](https://github.com/NapNeko/NapCatQQ), [Lagrange](https://github.com/LagrangeDev/Lagrange.Core)
> - **微信**: [Gewechat](https://github.com/Devo919/Gewechat)
## 核心功能 (继承自 OpenClaw)
- **多渠道消息支持**:连接 WhatsApp、Telegram、Slack、Discord、Google Chat、Signal、iMessage、WebChat 等
- **本地优先网关**:统一的控制平面,管理会话、渠道、工具和事件
- **语音唤醒与对话**:支持 macOS/iOS/Android 上的语音交互
- **实时画布**:代理驱动的可视化工作空间
- **浏览器控制**:通过 Chrome/Chromium 实现网页自动化
- **技能平台**:可扩展的技能系统,支持自定义工具
[完整功能列表](https://docs.openclaw.ai)
## 快速开始
### 环境要求
- **Node.js** ≥ 22
- **操作系统**: macOS, Linux, Windows (via WSL2)
### 安装
```bash
npm install -g openclaw@latest
# 运行安装向导(推荐)
openclaw onboard --install-daemon
```
### DeepSeek 配置示例
`~/.openclaw/openclaw.json` 中添加:
```json
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "${DEEPSEEK_API_KEY}",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "DeepSeek Chat",
"contextWindow": 64000,
"maxTokens": 8000
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000
}
]
}
}
},
"agent": {
"model": "deepseek/deepseek-chat"
}
}
```
或使用环境变量:
```bash
export DEEPSEEK_API_KEY=sk-xxxxxxxxx
```
### 启动网关
```bash
# 启动网关
openclaw gateway --port 18789 --verbose
# 发送消息
openclaw agent --message "你好,请介绍一下自己" --model deepseek/deepseek-chat
```
## 文档
- 📘 [DeepSeek 配置指南](docs/zh-CN/deepseek-guide.md)
- 📘 [钉钉配置指南](docs/zh-CN/dingtalk-guide.md) *(即将推出)*
- 📘 [飞书配置指南](docs/zh-CN/feishu-guide.md) *(即将推出)*
- 📘 [官方文档](https://docs.openclaw.ai) (英文)
## 价格优势
DeepSeekV3.2 定价(/百万 tokens
| 模型 | 输入 | 输出 | 缓存读 | 缓存写 |
|------|------|------|--------|--------|
| deepseek-chat | $0.14 | $0.28 | $0.014 | $0.14 |
| deepseek-reasoner | $0.55 | $2.19 | - | - |
**相比 Claude 和 GPT 系列DeepSeek 价格仅为其几分之一,同时性能优异!**
## 安全性
- **默认安全**DM 配对机制,未知发送者需要批准
- **沙箱模式**:支持 Docker 沙箱运行非主会话
- **权限控制**:基于白名单的渠道访问控制
详见 [安全指南](https://docs.openclaw.ai/gateway/security)
## 贡献
欢迎提交 Pull Request查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。
## 开源协议
MIT License - 详见 [LICENSE](LICENSE)
## 致谢
- 感谢 [OpenClaw 官方团队](https://github.com/openclaw/openclaw)
- 感谢 Peter Steinberger 和社区贡献者
- 特别感谢 DeepSeek 提供优秀的开源大模型
## 链接
- [OpenClaw 官网](https://openclaw.ai)
- [官方文档](https://docs.openclaw.ai)
- [Discord 社区](https://discord.gg/clawd)
- [GitHub 仓库](https://github.com/openclaw/openclaw)
---
**⚠️ 注意**: 本项目是 OpenClaw 的社区增强版本,主要面向中文用户。如需使用原版 OpenClaw请访问 [官方仓库](https://github.com/openclaw/openclaw)。

View File

@ -0,0 +1,43 @@
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "${DEEPSEEK_API_KEY}",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "Deep Seek Chat (V3.2 非思考模式)",
"contextWindow": 64000,
"maxTokens": 8000,
"cost": {
"input": 0.14,
"output": 0.28,
"cacheRead": 0.014,
"cacheWrite": 0.14
},
"compat": {
"supportsStore": false
}
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (V3.2 思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000,
"cost": {
"input": 0.55,
"output": 2.19
},
"compat": {
"supportsStore": false,
"supportsReasoningEffort": true
}
}
]
}
}
}
}

View File

@ -0,0 +1,91 @@
# DeepSeek 大模型配置指南
本文档说明如何在 OpenClaw 中配置 DeepSeek API。
## 获取 API 密钥
1. 访问 [DeepSeek 开放平台](https://platform.deepseek.com/)
2. 注册并登录您的账户
3. 在 API Keys 页面创建新的 API 密钥
## 配置步骤
### 方式一:环境变量
在您的环境中设置 `DEEPSEEK_API_KEY`
```bash
export DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxx
```
### 方式二:配置文件
`~/.openclaw/openclaw.json` 中添加以下配置:
```json
{
"models": {
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com/v1",
"apiKey": "sk-xxxxxxxxxxxxxxxxxx",
"api": "openai-responses",
"models": [
{
"id": "deepseek-chat",
"name": "DeepSeek Chat (V3.2)",
"contextWindow": 64000,
"maxTokens": 8000
},
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner (V3.2 思考模式)",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8000
}
]
}
}
}
}
```
## 使用模型
配置完成后,您可以在 OpenClaw 中使用以下模型标识符:
- `deepseek/deepseek-chat` - 标准对话模型(无思考模式)
- `deepseek/deepseek-reasoner` - 推理模型(启用思考模式)
示例:
```bash
openclaw chat --model deepseek/deepseek-chat "你好,请介绍一下自己"
```
## 定价说明
DeepSeek V3.2 定价(以每百万 tokens 计):
**deepseek-chat** (非思考模式):
- 输入:$0.14/M tokens
- 输出:$0.28/M tokens
- 缓存读取:$0.014/M tokens
- 缓存写入:$0.14/M tokens
**deepseek-reasoner** (思考模式):
- 输入:$0.55/M tokens
- 输出:$2.19/M tokens
## 注意事项
1. DeepSeek API 完全兼容 OpenAI API 格式
2. 推荐使用 `deepseek-chat` 进行日常对话
3. 对于复杂推理任务,使用 `deepseek-reasoner`
## 故障排查
如果遇到问题,请检查:
1. API 密钥是否正确设置
2. 网络是否能访问 `api.deepseek.com`
3. 配置文件 JSON 格式是否正确
更多信息请访问 [DeepSeek API 文档](https://platform.deepseek.com/docs)。

1686
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -82,6 +82,7 @@ import {
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs"; import { loadLogs } from "./controllers/logs";
import { t } from "./i18n";
const AVATAR_DATA_RE = /^data:/i; const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i; const AVATAR_HTTP_RE = /^https?:\/\//i;
@ -105,7 +106,7 @@ export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length; const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null; const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const chatDisabledReason = state.connected ? null : t("common.disconnected");
const isChat = state.tab === "chat"; const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@ -119,12 +120,12 @@ export function renderApp(state: AppViewState) {
<button <button
class="nav-collapse-toggle" class="nav-collapse-toggle"
@click=${() => @click=${() =>
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navCollapsed: !state.settings.navCollapsed, navCollapsed: !state.settings.navCollapsed,
})} })}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" title="${state.settings.navCollapsed ? t("app.expandSidebar") : t("app.collapseSidebar")}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}" aria-label="${state.settings.navCollapsed ? t("app.expandSidebar") : t("app.collapseSidebar")}"
> >
<span class="nav-collapse-toggle__icon">${icons.menu}</span> <span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button> </button>
@ -133,39 +134,43 @@ export function renderApp(state: AppViewState) {
<img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="OpenClaw" /> <img src="https://mintcdn.com/clawdhub/4rYvG-uuZrMK_URE/assets/pixel-lobster.svg?fit=max&auto=format&n=4rYvG-uuZrMK_URE&q=85&s=da2032e9eac3b5d9bfe7eb96ca6a8a26" alt="OpenClaw" />
</div> </div>
<div class="brand-text"> <div class="brand-text">
<div class="brand-title">OPENCLAW</div> <div class="brand-title">${t("app.title")}</div>
<div class="brand-sub">Gateway Dashboard</div> <div class="brand-sub">${t("app.subtitle")}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="topbar-status"> <div class="topbar-status">
<div class="pill"> <div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span> <span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span> <span>${t("app.health")}</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span> <span class="mono">${state.connected ? t("app.healthOk") : t("app.healthOffline")}</span>
</div> </div>
${renderThemeToggle(state)} ${renderThemeToggle(state)}
</div> </div>
</header> </header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => { ${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false; const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab); const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html` const groupLabel = group.label === "Chat" ? t("nav.chat") :
group.label === "Control" ? t("nav.control") :
group.label === "Agent" ? t("nav.agent") :
group.label === "Settings" ? t("nav.settings") : group.label;
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}"> <div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button <button
class="nav-label" class="nav-label"
@click=${() => { @click=${() => {
const next = { ...state.settings.navGroupsCollapsed }; const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed; next[group.label] = !isGroupCollapsed;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
navGroupsCollapsed: next, navGroupsCollapsed: next,
}); });
}} }}
aria-expanded=${!isGroupCollapsed} aria-expanded=${!isGroupCollapsed}
> >
<span class="nav-label__text">${group.label}</span> <span class="nav-label__text">${groupLabel}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span> <span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button> </button>
<div class="nav-group__items"> <div class="nav-group__items">
@ -173,10 +178,10 @@ export function renderApp(state: AppViewState) {
</div> </div>
</div> </div>
`; `;
})} })}
<div class="nav-group nav-group--links"> <div class="nav-group nav-group--links">
<div class="nav-label nav-label--static"> <div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span> <span class="nav-label__text">${t("nav.resources")}</span>
</div> </div>
<div class="nav-group__items"> <div class="nav-group__items">
<a <a
@ -184,10 +189,10 @@ export function renderApp(state: AppViewState) {
href="https://docs.openclaw.ai" href="https://docs.openclaw.ai"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
title="Docs (opens in new tab)" title="${t("app.openDocs")}"
> >
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> <span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span> <span class="nav-item__text">${t("app.docs")}</span>
</a> </a>
</div> </div>
</div> </div>
@ -200,383 +205,383 @@ export function renderApp(state: AppViewState) {
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ${state.lastError
? html`<div class="pill danger">${state.lastError}</div>` ? html`<div class="pill danger">${state.lastError}</div>`
: nothing} : nothing}
${isChat ? renderChatControls(state) : nothing} ${isChat ? renderChatControls(state) : nothing}
</div> </div>
</section> </section>
${state.tab === "overview" ${state.tab === "overview"
? renderOverview({ ? renderOverview({
connected: state.connected, connected: state.connected,
hello: state.hello, hello: state.hello,
settings: state.settings, settings: state.settings,
password: state.password, password: state.password,
lastError: state.lastError, lastError: state.lastError,
presenceCount, presenceCount,
sessionsCount, sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null, cronEnabled: state.cronStatus?.enabled ?? null,
cronNext, cronNext,
lastChannelsRefresh: state.channelsLastSuccess, lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream(); state.resetToolStream();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
}, },
onConnect: () => state.connect(), onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
}) })
: nothing} : nothing}
${state.tab === "channels" ${state.tab === "channels"
? renderChannels({ ? renderChannels({
connected: state.connected, connected: state.connected,
loading: state.channelsLoading, loading: state.channelsLoading,
snapshot: state.channelsSnapshot, snapshot: state.channelsSnapshot,
lastError: state.channelsError, lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess, lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage, whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl, whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected, whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy, whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema, configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading, configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm, configForm: state.configForm,
configUiHints: state.configUiHints, configUiHints: state.configUiHints,
configSaving: state.configSaving, configSaving: state.configSaving,
configFormDirty: state.configFormDirty, configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState, nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId, nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe), onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force), onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(), onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(), onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value), onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(), onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(), onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) => onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile), state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(), onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) => onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value), state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(), onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(), onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(), onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
}) })
: nothing} : nothing}
${state.tab === "instances" ${state.tab === "instances"
? renderInstances({ ? renderInstances({
loading: state.presenceLoading, loading: state.presenceLoading,
entries: state.presenceEntries, entries: state.presenceEntries,
lastError: state.presenceError, lastError: state.presenceError,
statusMessage: state.presenceStatus, statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state), onRefresh: () => loadPresence(state),
}) })
: nothing} : nothing}
${state.tab === "sessions" ${state.tab === "sessions"
? renderSessions({ ? renderSessions({
loading: state.sessionsLoading, loading: state.sessionsLoading,
result: state.sessionsResult, result: state.sessionsResult,
error: state.sessionsError, error: state.sessionsError,
activeMinutes: state.sessionsFilterActive, activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit, limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal, includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown, includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath, basePath: state.basePath,
onFiltersChange: (next) => { onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit; state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown; state.sessionsIncludeUnknown = next.includeUnknown;
}, },
onRefresh: () => loadSessions(state), onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch), onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key), onDelete: (key) => deleteSession(state, key),
}) })
: nothing} : nothing}
${state.tab === "cron" ${state.tab === "cron"
? renderCron({ ? renderCron({
loading: state.cronLoading, loading: state.cronLoading,
status: state.cronStatus, status: state.cronStatus,
jobs: state.cronJobs, jobs: state.cronJobs,
error: state.cronError, error: state.cronError,
busy: state.cronBusy, busy: state.cronBusy,
form: state.cronForm, form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id) ? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [], : state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {}, channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [], channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId, runsJobId: state.cronRunsJobId,
runs: state.cronRuns, runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }), onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(), onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state), onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job), onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job), onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId), onLoadRuns: (jobId) => loadCronRuns(state, jobId),
}) })
: nothing} : nothing}
${state.tab === "skills" ${state.tab === "skills"
? renderSkills({ ? renderSkills({
loading: state.skillsLoading, loading: state.skillsLoading,
report: state.skillsReport, report: state.skillsReport,
error: state.skillsError, error: state.skillsError,
filter: state.skillsFilter, filter: state.skillsFilter,
edits: state.skillEdits, edits: state.skillEdits,
messages: state.skillMessages, messages: state.skillMessages,
busyKey: state.skillsBusyKey, busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next), onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }), onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled), onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value), onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key), onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) => onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId), installSkill(state, skillKey, name, installId),
}) })
: nothing} : nothing}
${state.tab === "nodes" ${state.tab === "nodes"
? renderNodes({ ? renderNodes({
loading: state.nodesLoading, loading: state.nodesLoading,
nodes: state.nodes, nodes: state.nodes,
devicesLoading: state.devicesLoading, devicesLoading: state.devicesLoading,
devicesError: state.devicesError, devicesError: state.devicesError,
devicesList: state.devicesList, devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null), configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading, configLoading: state.configLoading,
configSaving: state.configSaving, configSaving: state.configSaving,
configDirty: state.configFormDirty, configDirty: state.configFormDirty,
configFormMode: state.configFormMode, configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading, execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving, execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty, execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm, execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget, execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId, execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state), onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state), onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId), onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId), onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) => onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }), rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }), revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state), onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => { onLoadExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return loadExecApprovals(state, target); return loadExecApprovals(state, target);
}, },
onBindDefault: (nodeId) => { onBindDefault: (nodeId) => {
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else { } else {
removeConfigFormValue(state, ["tools", "exec", "node"]); removeConfigFormValue(state, ["tools", "exec", "node"]);
} }
}, },
onBindAgent: (agentIndex, nodeId) => { onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, basePath, nodeId); updateConfigFormValue(state, basePath, nodeId);
} else { } else {
removeConfigFormValue(state, basePath); removeConfigFormValue(state, basePath);
} }
}, },
onSaveBindings: () => saveConfig(state), onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => { onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind; state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId; state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null; state.execApprovalsSnapshot = null;
state.execApprovalsForm = null; state.execApprovalsForm = null;
state.execApprovalsDirty = false; state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null; state.execApprovalsSelectedAgent = null;
}, },
onExecApprovalsSelectAgent: (agentId) => { onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId; state.execApprovalsSelectedAgent = agentId;
}, },
onExecApprovalsPatch: (path, value) => onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value), updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path), removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => { onSaveExecApprovals: () => {
const target = const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId } ? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const }; : { kind: "gateway" as const };
return saveExecApprovals(state, target); return saveExecApprovals(state, target);
}, },
}) })
: nothing} : nothing}
${state.tab === "chat" ${state.tab === "chat"
? renderChat({ ? renderChat({
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.chatAttachments = []; state.chatAttachments = [];
state.chatStream = null; state.chatStream = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
state.chatQueue = []; state.chatQueue = [];
state.resetToolStream(); state.resetToolStream();
state.resetChatScroll(); state.resetChatScroll();
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
sessionKey: next, sessionKey: next,
lastActiveSessionKey: next, lastActiveSessionKey: next,
}); });
void state.loadAssistantIdentity(); void state.loadAssistantIdentity();
void loadChatHistory(state); void loadChatHistory(state);
void refreshChatAvatar(state); void refreshChatAvatar(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
showThinking, showThinking,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
compactionStatus: state.compactionStatus, compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl, assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages, messages: state.chatMessages,
toolMessages: state.chatToolMessages, toolMessages: state.chatToolMessages,
stream: state.chatStream, stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt, streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage, draft: state.chatMessage,
queue: state.chatQueue, queue: state.chatQueue,
connected: state.connected, connected: state.connected,
canSend: state.connected, canSend: state.connected,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
error: state.lastError, error: state.lastError,
sessions: state.sessionsResult, sessions: state.sessionsResult,
focusMode: chatFocus, focusMode: chatFocus,
onRefresh: () => { onRefresh: () => {
state.resetToolStream(); state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
}, },
onToggleFocusMode: () => { onToggleFocusMode: () => {
if (state.onboarding) return; if (state.onboarding) return;
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatFocusMode: !state.settings.chatFocusMode, chatFocusMode: !state.settings.chatFocusMode,
}); });
}, },
onChatScroll: (event) => state.handleChatScroll(event), onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments, attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next), onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId), canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }), state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen, sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent, sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError, sidebarError: state.sidebarError,
splitRatio: state.splitRatio, splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content), onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(), onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName, assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar, assistantAvatar: state.assistantAvatar,
}) })
: nothing} : nothing}
${state.tab === "config" ${state.tab === "config"
? renderConfig({ ? renderConfig({
raw: state.configRaw, raw: state.configRaw,
originalRaw: state.configRawOriginal, originalRaw: state.configRawOriginal,
valid: state.configValid, valid: state.configValid,
issues: state.configIssues, issues: state.configIssues,
loading: state.configLoading, loading: state.configLoading,
saving: state.configSaving, saving: state.configSaving,
applying: state.configApplying, applying: state.configApplying,
updating: state.updateRunning, updating: state.updateRunning,
connected: state.connected, connected: state.connected,
schema: state.configSchema, schema: state.configSchema,
schemaLoading: state.configSchemaLoading, schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints, uiHints: state.configUiHints,
formMode: state.configFormMode, formMode: state.configFormMode,
formValue: state.configForm, formValue: state.configForm,
originalValue: state.configFormOriginal, originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery, searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection, activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection, activeSubsection: state.configActiveSubsection,
onRawChange: (next) => { onRawChange: (next) => {
state.configRaw = next; state.configRaw = next;
}, },
onFormModeChange: (mode) => (state.configFormMode = mode), onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query), onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => { onSectionChange: (section) => {
state.configActiveSection = section; state.configActiveSection = section;
state.configActiveSubsection = null; state.configActiveSubsection = null;
}, },
onSubsectionChange: (section) => (state.configActiveSubsection = section), onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
onApply: () => applyConfig(state), onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state), onUpdate: () => runUpdate(state),
}) })
: nothing} : nothing}
${state.tab === "debug" ${state.tab === "debug"
? renderDebug({ ? renderDebug({
loading: state.debugLoading, loading: state.debugLoading,
status: state.debugStatus, status: state.debugStatus,
health: state.debugHealth, health: state.debugHealth,
models: state.debugModels, models: state.debugModels,
heartbeat: state.debugHeartbeat, heartbeat: state.debugHeartbeat,
eventLog: state.eventLog, eventLog: state.eventLog,
callMethod: state.debugCallMethod, callMethod: state.debugCallMethod,
callParams: state.debugCallParams, callParams: state.debugCallParams,
callResult: state.debugCallResult, callResult: state.debugCallResult,
callError: state.debugCallError, callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next), onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next), onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state), onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state), onCall: () => callDebugMethod(state),
}) })
: nothing} : nothing}
${state.tab === "logs" ${state.tab === "logs"
? renderLogs({ ? renderLogs({
loading: state.logsLoading, loading: state.logsLoading,
error: state.logsError, error: state.logsError,
file: state.logsFile, file: state.logsFile,
entries: state.logsEntries, entries: state.logsEntries,
filterText: state.logsFilterText, filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters, levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow, autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated, truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next), onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => { onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled }; state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
}, },
onToggleAutoFollow: (next) => (state.logsAutoFollow = next), onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }), onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label), onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event), onScroll: (event) => state.handleLogsScroll(event),
}) })
: nothing} : nothing}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)} ${renderGatewayUrlConfirmation(state)}

22
ui/src/ui/i18n.ts Normal file
View File

@ -0,0 +1,22 @@
import { zhCN } from "./locales/zh-CN";
const currentLocale = "zh-CN"; // Default to Chinese
const locales: Record<string, any> = {
"zh-CN": zhCN,
};
export function t(key: string): string {
const keys = key.split(".");
let value: any = locales[currentLocale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; // Return key if not found
}
}
return typeof value === "string" ? value : key;
}

View File

@ -0,0 +1,89 @@
export const zhCN = {
app: {
title: "OPENCLAW",
subtitle: "网关仪表板",
expandSidebar: "展开侧边栏",
collapseSidebar: "收起侧边栏",
health: "健康状态",
healthOk: "正常",
healthOffline: "离线",
resources: "资源",
docs: "文档",
openDocs: "文档 (打开新标签页)",
},
nav: {
chat: "聊天",
control: "控制",
agent: "代理",
settings: "设置",
resources: "资源",
},
tabs: {
overview: "概览",
channels: "渠道",
chat: "聊天",
sessions: "会话",
cron: "定时任务",
skills: "技能",
nodes: "节点",
instances: "实例",
config: "配置",
debug: "调试",
logs: "日志",
},
overview: {
title: "概览",
subtitle: "查看网关的运行状态和摘要",
},
channels: {
title: "渠道",
subtitle: "管理消息渠道连接",
},
chat: {
title: "聊天",
subtitle: "与您的 AI 助手互动",
},
sessions: {
title: "会话",
subtitle: "查看活跃的代理会话",
},
cron: {
title: "定时任务",
subtitle: "管理定时作业",
},
skills: {
title: "技能",
subtitle: "管理代理能力",
},
nodes: {
title: "节点",
subtitle: "管理计算节点",
},
instances: {
title: "实例",
subtitle: "查看连接的客户端实例",
},
config: {
title: "配置",
subtitle: "编辑网关设置",
},
debug: {
title: "调试",
subtitle: "高级工具和状态",
},
logs: {
title: "日志",
subtitle: "查看实时系统日志",
},
common: {
loading: "加载中...",
error: "错误",
save: "保存",
cancel: "取消",
delete: "删除",
edit: "编辑",
refresh: "刷新",
disconnected: "与网关断开连接。",
}
};

View File

@ -1,4 +1,5 @@
import type { IconName } from "./icons.js"; import type { IconName } from "./icons.js";
import { t } from "./i18n.js";
export const TAB_GROUPS = [ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] }, { label: "Chat", tabs: ["chat"] },
@ -132,27 +133,27 @@ export function iconForTab(tab: Tab): IconName {
export function titleForTab(tab: Tab) { export function titleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Overview"; return t("tabs.overview");
case "channels": case "channels":
return "Channels"; return t("tabs.channels");
case "instances": case "instances":
return "Instances"; return t("tabs.instances");
case "sessions": case "sessions":
return "Sessions"; return t("tabs.sessions");
case "cron": case "cron":
return "Cron Jobs"; return t("tabs.cron");
case "skills": case "skills":
return "Skills"; return t("tabs.skills");
case "nodes": case "nodes":
return "Nodes"; return t("tabs.nodes");
case "chat": case "chat":
return "Chat"; return t("tabs.chat");
case "config": case "config":
return "Config"; return t("tabs.config");
case "debug": case "debug":
return "Debug"; return t("tabs.debug");
case "logs": case "logs":
return "Logs"; return t("tabs.logs");
default: default:
return "Control"; return "Control";
} }
@ -161,28 +162,29 @@ export function titleForTab(tab: Tab) {
export function subtitleForTab(tab: Tab) { export function subtitleForTab(tab: Tab) {
switch (tab) { switch (tab) {
case "overview": case "overview":
return "Gateway status, entry points, and a fast health read."; return t("overview.subtitle");
case "channels": case "channels":
return "Manage channels and settings."; return t("channels.subtitle");
case "instances": case "instances":
return "Presence beacons from connected clients and nodes."; return t("instances.subtitle");
case "sessions": case "sessions":
return "Inspect active sessions and adjust per-session defaults."; return t("sessions.subtitle");
case "cron": case "cron":
return "Schedule wakeups and recurring agent runs."; return t("cron.subtitle");
case "skills": case "skills":
return "Manage skill availability and API key injection."; return t("skills.subtitle");
case "nodes": case "nodes":
return "Paired devices, capabilities, and command exposure."; return t("nodes.subtitle");
case "chat": case "chat":
return "Direct gateway chat session for quick interventions."; return t("chat.subtitle");
case "config": case "config":
return "Edit ~/.openclaw/openclaw.json safely."; return t("config.subtitle");
case "debug": case "debug":
return "Gateway snapshots, events, and manual RPC calls."; return t("debug.subtitle");
case "logs": case "logs":
return "Live tail of the gateway file logs."; return t("logs.subtitle");
default: default:
return ""; return "";
} }
} }