feat: improve gateway compatibility and complete i18n cleanup

This commit is contained in:
晴天
2026-04-01 15:06:25 +08:00
parent 57b8b25946
commit b427a6b000
59 changed files with 6830 additions and 964 deletions

View File

@@ -5,6 +5,22 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.11.0] - 2026-03-31
### 新功能 (Features)
- **本地版本准备** — ClawPanel 程序版本已对齐到 `0.11.0`,同步覆盖 `package.json``package-lock.json``src-tauri/Cargo.toml``src-tauri/tauri.conf.json``docs/index.html`
- **OpenClaw 3.28 策略映射** — 新增 `0.11.0` → 官方版 `2026.3.28` / 汉化版 `2026.3.28-zh.2` 的推荐稳定版映射,同时保留 `0.9.x` 旧客户端的保守推荐策略
### 修复 (Fixes)
- **仪表盘运行态误导** — Dashboard 仅在 Gateway 运行时才请求 `getStatusSummary()`Gateway 停止时清空旧缓存,并将 Web `file-read` 来源明确标注为 `openclaw.json / 本地安装`,避免把本地配置快照误读成运行态
- **版本同步脚本** — `npm run version:set` / `npm run version:sync` 现在会一并同步 `package-lock.json`,避免程序版本与锁文件版本再次漂移
### 改进 (Improvements)
- **维护文档** — 更新 `docs/version-maintenance.md``docs/openclaw-2026-3-28-compatibility.md`,补齐 `0.11.0` 维护要点、旧客户端兼容边界与当前 Web 写入链路结论
## [0.10.0] - 2026-03-26
### 新功能 (Features)

View File

@@ -34,7 +34,7 @@
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。内置晴辰助手支持工具调用,晴辰云 AI 接口一键接入。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。支持 11 种语言。",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.10.0",
"softwareVersion": "0.11.0",
"author": {
"@type": "Organization",
"name": "晴辰云 QingchenCloud",
@@ -1155,7 +1155,7 @@
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container-sm" style="position:relative;z-index:10">
<div class="section-header">
<div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.10.0 最新版</span></div>
<div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.11.0 最新版</span></div>
<h2 class="reveal section-title" data-i18n="dl.title"><span class="gradient-text">下载安装</span></h2>
<p class="reveal section-desc" data-i18n="dl.desc">选择你的操作系统,一键下载安装</p>
</div>
@@ -1165,11 +1165,11 @@
<h3>macOS</h3>
<p class="dl-desc" data-i18n="dl.mac.d">支持 Apple Silicon 和 Intel 芯片</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_aarch64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_aarch64.dmg" target="_blank" rel="noopener">
Apple Silicon (M1/M2/M3/M4)
<span class="dl-format">.dmg</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_x64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_x64.dmg" target="_blank" rel="noopener">
<span data-i18n="dl.mac.intel">Intel 芯片</span>
<span class="dl-format">.dmg</span>
</a>
@@ -1187,11 +1187,11 @@
<h3>Windows</h3>
<p class="dl-desc" data-i18n="dl.win.d">支持 Windows 10 及以上版本</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_x64-setup.exe" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_x64-setup.exe" target="_blank" rel="noopener">
<span data-i18n="dl.win.exe">安装程序</span>
<span class="dl-format">.exe</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_x64_en-US.msi" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_x64_en-US.msi" target="_blank" rel="noopener">
<span data-i18n="dl.win.msi">MSI 安装包</span>
<span class="dl-format">.msi</span>
</a>
@@ -1202,11 +1202,11 @@
<h3>Linux</h3>
<p class="dl-desc" data-i18n="dl.linux.d">支持主流 Linux 发行版</p>
<div class="dl-links">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_amd64.AppImage" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_amd64.AppImage" target="_blank" rel="noopener">
<span data-i18n="dl.linux.ai">通用版</span>
<span class="dl-format">.AppImage</span>
</a>
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.10.0_amd64.deb" target="_blank" rel="noopener">
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.11.0_amd64.deb" target="_blank" rel="noopener">
Debian / Ubuntu
<span class="dl-format">.deb</span>
</a>

View File

@@ -0,0 +1,463 @@
# OpenClaw 2026.3.28 兼容性与旧版客户端风险说明
更新时间2026-03-31 13:02 +08:00
## 一、文档目的
这份文档用于说明以下三个问题:
1. ClawPanel 当前是否应该将 OpenClaw `2026.3.28` / `2026.3.28-zh.2` 设为推荐稳定版。
2. 旧版 ClawPanel 客户端在连接 OpenClaw `2026.3.28` 时,兼容边界在哪里。
3. 后续发布策略应该如何兼顾“新版面板适配 3.28”与“旧客户端不要被错误引导升级”。
这份文档不是 OpenClaw 3.28 的功能手册,而是面向 ClawPanel 维护者的兼容性决策说明。
## 二、结论摘要
### 1. 当前结论
OpenClaw `2026.3.28` 并不是“完全不兼容”。
就当前仓库代码来看:
1. **当前主干代码已经具备基础兼容能力**
2. **推荐稳定版策略文件已经可以指向 `2026.3.28` / `2026.3.28-zh.2`**
3. **真正需要重点关注的是旧版客户端的写入兼容性,而不是只读兼容性**
### 2. 不应简单得出的错误结论
以下两种判断都不准确:
1. “所有版本的 ClawPanel 都已经可以安全推荐 3.28。”
2. “旧版客户端完全不能连接 3.28。”
更准确的结论是:
> 旧版客户端大多可以读取和连接 OpenClaw 3.28,但不一定可以安全地修改其配置。
### 3. 推荐策略结论
后续发布不建议把 `2026.3.28` 直接作为所有历史 ClawPanel 版本的推荐稳定版。
更稳妥的策略是:
1. **仅对已经验证过的面板版本推荐 3.28**
2. **对历史旧版客户端保持旧稳定版映射,或至少不给 3.28 做默认推荐**
3. **对旧客户端连接 3.28 的场景,增加明确的风险提示,尤其是配置写入场景**
## 三、为什么源码改了稳定版,已发布旧桌面客户端可能仍显示旧值
ClawPanel 当前有两套版本策略读取路径:
### 1. Web / dev-api 模式
Web 模式下,版本策略由 Node 后端在运行时读取:
- 文件:`scripts/dev-api.js`
- 关键常量:`VERSION_POLICY_PATH`
- 数据源:仓库根目录 `openclaw-version-policy.json`
这种模式下,只要修改策略文件并重新启动 Web 后端,新的推荐版就会立即生效。
### 2. Tauri 桌面端
桌面端不是运行时读 JSON 文件,而是编译时直接把策略文件嵌入到二进制:
- 文件:`src-tauri/src/commands/config.rs`
- 关键实现:`include_str!("../../../openclaw-version-policy.json")`
这意味着:
1. 已发布的桌面安装包会继续使用它编译当时内嵌进去的策略。
2. 仓库里后续修改 `openclaw-version-policy.json`,不会自动影响用户手里的旧桌面安装包。
3. 如果要让桌面端真正把稳定版从 `2026.3.13` 切到 `2026.3.28`,必须重新构建并重新发布桌面端。
### 3. 这件事对旧客户端兼容策略的影响
这实际上天然把“旧客户端兼容”分成了两类:
1. **已发布旧桌面客户端**:不会自动切到新推荐版。
2. **未来重新打包的旧版本分支 / Web 旧客户端**:会直接吃到当前策略文件。
所以后续讨论“是否推荐 3.28”时,不能只看仓库代码,还要看对应客户端是否已经重新发布。
## 四、兼容性需要拆成两种:只读兼容 vs 写入兼容
### 1. 只读兼容
只读兼容指旧版客户端在连接 OpenClaw 3.28 后,是否还能正常:
1. 获取版本信息。
2. 查看服务状态。
3. 打开 Dashboard / About / Services 等页面。
4. 执行基础聊天和基础 RPC 请求。
从当前代码和已知链路看,这部分整体风险相对可控。
原因是:
1. 旧面板很多页面只是读取 `getVersionInfo()``getServicesStatus()``readOpenclawConfig()` 等接口返回结果。
2. OpenClaw 3.28 的新能力即使旧客户端不认识,通常也只是“页面不展示”,并不必然导致崩溃。
3. 当前版本比较逻辑已经支持去掉 `-zh.x` 后缀做基础版本判断,因此 `2026.3.28-zh.2` 不会天然被错误识别成不同主版本。
### 2. 写入兼容
写入兼容才是重点。
这里的风险不在于“旧客户端看不懂新字段”,而在于:
> 旧客户端一旦保存配置,会不会把 OpenClaw 3.28 新增的字段写没、改坏,或者回写成旧结构。
这是后续是否允许旧客户端“安全管理 3.28”最关键的判断标准。
## 五、旧客户端连接 3.28 时,当前最主要的风险点
## 5.1 风险一:整份 openclaw.json 回写
这是当前最需要警惕的风险。
### Tauri 当前实现相对安全
在当前仓库版本里Tauri 的 `write_openclaw_config()` 已经做了保留未知字段的处理:
- 文件:`src-tauri/src/commands/config.rs`
- 关键函数:`write_openclaw_config()`
- 合并逻辑:`merge_configs_preserving_fields()`
写入流程大致是:
1. 先读取现有配置。
2. 用新配置覆盖旧配置中的同名字段。
3. 保留未被新配置覆盖的已有字段。
4. 最后只清理 ClawPanel 自己的 UI 字段。
这意味着:
1. 如果 OpenClaw 3.28 新增了旧客户端不认识的合法字段,当前 Tauri 主干代码在整份保存时,**有机会把这些字段保留下来**。
2. 因此当前主干 Tauri 对 3.28 的配置写入兼容性,仍然属于当前仓库里更稳的一侧。
### Web dev-api 当前实现已改善,但历史旧客户端仍然有风险
在当前仓库版本里,`scripts/dev-api.js``write_openclaw_config({ config })` 已不再是“直接写传入对象”,而是:
1. 先读取现有 `openclaw.json`
2. 通过 `mergeConfigsPreservingFields(existing, config)` 合并新旧配置。
3. 再执行 `stripUiFields(merged)`
4. 最后写回磁盘。
这意味着:
1. **当前主干 Web dev-api 的整份保存风险,已经明显低于之前的直接覆盖实现。**
2. 对于“旧客户端不认识、但本次前端没有主动覆盖”的字段,当前 Web 链路通常也能保留下来。
3. 但这仍然**不是零风险**
- 如果旧客户端在同一路径上用旧结构覆盖新结构,语义仍可能被改坏。
- 数组类字段、整块对象替换、用户手动删字段等场景,仍然可能破坏 3.28 配置。
- 已发布的历史旧 Web / 桌面客户端如果还没有这套 merge 逻辑,风险依旧存在。
### 风险结论
因此在“旧客户端兼容 3.28”这个问题上:
1. **Tauri 主干版本的整份配置写入风险较低,但不是零风险。**
2. **当前主干 Web dev-api 也已经具备 merge 保留未知字段能力,但整体风险仍略高于只读场景。**
3. **任何历史旧版客户端,如果尚未具备 merge 保留未知字段能力,都不应默认推荐用户去管理 3.28 配置。**
## 5.2 风险二:配置编辑器属于高风险入口
文件:`src/pages/services.js`
当前配置编辑器的流程是:
1. `api.readOpenclawConfig()` 读取完整配置。
2. 用户在文本框中编辑 JSON。
3. `api.writeOpenclawConfig(config)` 保存。
这个入口的问题在于:
1. 用户会把它当作“完整配置编辑器”。
2. 但旧客户端不一定理解 OpenClaw 3.28 的所有新字段语义。
3. 一旦旧客户端底层写入链路不保留未知字段,保存行为就可能破坏 3.28 配置。
因此对于旧客户端来说,这个页面属于:
- **高风险写入口**。
如果后续要做旧客户端兼容保护,这里应该是首批增加警告或限制的页面。
## 5.3 风险三Agent 高级配置字段不一定完全跟上 3.28
OpenClaw 3.28 在 Agent 侧的能力明显增强,例如:
1. 推理模式细分。
2. Fallback 模型链。
3. Heartbeat 轻量会话配置。
4. 更完整的 tools / sessions / subagents / runtime / params 配置。
当前主干代码里ClawPanel 已经不是“完全没有 Agent 高级能力”,但也还没有完整承接 OpenClaw 3.28 的所有新能力。
### 当前已经有基础支持的部分
从当前代码看,以下能力已经有了基础承接:
1. Agent 详情页。
2. Bootstrap 文件读写。
3. `model.primary + fallbacks`
4. `thinkingDefault`
5. `skills`
6. `tools`
相关文件包括:
- `src/pages/agent-detail.js`
- `src-tauri/src/commands/agent.rs`
- `scripts/dev-api.js`
### 当前仍未完整对齐的部分
以下字段和能力,目前仍然更像“部分支持”或“未完整产品化”:
1. `reasoningDefault`
2. `fastModeDefault`
3. `heartbeat`
4. `subagents`
5. `sandbox`
6. `params`
7. `runtime`
8. 更完整的会话生命周期配置
9. 工具目录、生效工具、工具审批等高级工具系统
这意味着:
1. 新版主干客户端连接 3.28 时,**基础 Agent 管理大体可用**。
2. 历史旧客户端连接 3.28 时,**只要涉及高级 Agent 配置写入,就存在结构丢失或无法表达的风险**。
## 5.4 风险四:旧客户端可能“能看、能连、能聊”,但不代表“能安全写”
这是维护决策里最容易被忽略的一点。
很多时候用户看到旧客户端还能:
1. 显示版本号。
2. 启动 Gateway。
3. 正常聊天。
就会误以为“完全兼容”。
实际上这只能说明:
- **基础运行兼容**。
它不能说明:
- **配置写入兼容**。
- **高级能力兼容**。
- **长期运维兼容**。
因此在版本发布策略上,不能把“能连上 3.28”直接等同于“可以安全把 3.28 设成旧客户端的推荐稳定版”。
## 六、当前主干代码对 3.28 的真实支持情况
## 6.1 已经具备基础兼容能力的部分
当前主干分支已经有以下基础承接能力:
1. **版本策略统一文件**
- `openclaw-version-policy.json`
2. **前后端统一版本信息读取**
- Web`scripts/dev-api.js`
- Tauri`src-tauri/src/commands/config.rs`
3. **Agent 文件管理**
- `list_agent_files`
- `read_agent_file`
- `write_agent_file`
4. **Agent 高级基础配置**
- primary model
- fallbacks
- thinkingDefault
- tools
- skills
5. **基础会话能力**
- `sessions.list`
- `sessions.delete`
- `sessions.reset`
因此“ClawPanel 完全不支持 3.28”并不成立。
## 6.2 当前仍需补齐的部分
要说“ClawPanel 已完整支持 3.28 新能力”,当前也还做不到。
主要缺口包括:
1. Tauri / Web 对 Agent 高级字段的承接还不完全对齐。
2. 会话系统仍然只覆盖部分方法。
3. 工具系统仍然缺少目录与生效状态视图。
4. Heartbeat / lightContext / isolatedSession 等高级能力还没有完整面板入口。
5. 旧客户端缺少针对 3.28 的显式风险提示和保护策略。
## 七、为什么不建议把 3.28 直接推荐给所有历史客户端
如果把 `openclaw-version-policy.json` 中所有历史 `panels.*` 全部统一改成 `2026.3.28` / `2026.3.28-zh.2`,会有三个风险:
### 1. 风险一:未来重打旧版本分支时会误导用户
策略文件本身支持按面板版本映射推荐版。
如果后续把所有历史 panel 版本都指向 3.28,那么:
1. 历史旧版本一旦重新构建。
2. 或 Web 模式旧代码直接读取最新策略文件。
3. 用户就会被引导去安装 3.28。
但这些旧客户端并未必具备安全管理 3.28 配置的能力。
### 2. 风险二:旧客户端缺少写入保护
对于旧客户端来说,真正危险的是:
1. 打开配置编辑器。
2. 修改 Agent 高级配置。
3. 写入 openclaw.json。
如果它们没有“保留未知字段”的能力,就可能把 3.28 的新字段覆盖掉。
### 3. 风险三:推荐版意味着维护者背书
一旦某版本被标成“推荐稳定版”,用户会自然理解为:
1. 当前面板版本已经验证过。
2. 可以放心安装。
3. 后续出现配置或行为问题,默认认为是面板 bug。
如果实际上只是“能读能连,但不保证安全写”,就不适合把它作为所有历史客户端的推荐稳定版。
## 八、建议采用的版本推荐策略
## 8.1 分层推荐,而不是一刀切推荐
推荐策略应分为三层:
### A. 已验证的新面板版本
这类版本已经完成对 3.28 的验证,可以推荐:
- official: `2026.3.28`
- chinese: `2026.3.28-zh.2`
### B. 历史旧面板版本
这类版本不建议默认推荐 3.28。
可以继续保持旧稳定版,例如:
- official: 继续使用旧验证版
- chinese: 继续使用旧验证版
### C. 未知来源 / 手动升级用户
允许用户自己切换到 3.28,但必须承担自测兼容性的责任。
适合继续保留以下原则:
1. 关于页允许手动切换版本。
2. 服务页只默认推荐“已验证稳定版”。
3. 高于推荐版时显示风险提示。
## 8.2 对旧客户端,应至少明确支持两档兼容声明
后续在说明和 UI 提示中,建议把兼容性拆成两档:
### 只读兼容
表示:
1. 可以连接 3.28。
2. 可以查看状态。
3. 可以做基础聊天。
4. 不代表所有配置编辑都安全。
### 完整管理兼容
表示:
1. 已验证当前客户端对 3.28 的配置读写安全。
2. 已验证关键高级字段不会被误删。
3. 可以作为推荐稳定版公开引导用户升级。
## 九、如果后续要做旧客户端保护,优先级建议
## P0先调整推荐策略
最先应该做的不是补全所有 3.28 UI而是先控制策略风险
1. 不再给所有历史面板版本一刀切推荐 3.28。
2. 只对当前验证通过的面板版本设置 3.28 推荐。
3. 历史版本保留旧推荐版映射。
## P1补旧客户端风险提示
优先在这些高风险入口增加提示:
1. 关于页版本管理。
2. 服务页配置编辑器。
3. Agent 高级配置页面。
提示目标不是阻止用户使用 3.28,而是明确说明:
- 当前客户端可能仅保证只读兼容或基础运行兼容。
- 高级配置写入需谨慎。
## P2补 Web 写入链路的未知字段保留能力
当前 `scripts/dev-api.js``write_openclaw_config()` 风险高于 Tauri。
如果要提高 3.28 在 Web 模式下的兼容性,建议优先把这条链路改成和 Tauri 一样的“先读现有配置、再 merge 保留未知字段”的模式。
## P3逐步补齐 3.28 高级能力
后续再考虑:
1. reasoning / fast mode
2. heartbeat 高级配置
3. sessions 全量方法
4. tools.catalog / tools.effective
5. requireApproval / 审批策略
6. subagents / sandbox / runtime / params
## 十、维护建议
后续在维护 OpenClaw 推荐版时,建议遵循以下原则:
1. **推荐稳定版不等于上游最新版。**
2. **是否推荐,取决于当前面板版本是否已经验证过读写兼容。**
3. **旧客户端兼容性优先看写入风险,而不是页面能不能打开。**
4. **Tauri 已发布旧包与仓库当前代码要分开判断。**
5. **Web 模式与桌面模式的写入安全性不能混为一谈。**
## 十一、最终结论
最终建议如下:
1. **可以让当前主干 ClawPanel 以 OpenClaw `2026.3.28` / `2026.3.28-zh.2` 为推荐稳定版。**
2. **不要把 3.28 直接作为所有历史 ClawPanel 版本的推荐稳定版。**
3. **对旧客户端,应将兼容性定义为“基础运行兼容优先、配置写入兼容待验证”。**
4. **后续若要正式公开推广 3.28,应优先补策略分层和写入保护。**
换句话说:
> 3.28 可以成为新面板的推荐稳定版,但不应成为所有旧客户端的默认推荐版。
## 十二、建议后续动作清单
建议按以下顺序推进:
1. 重新审视 `openclaw-version-policy.json` 的历史版本映射。
2. 为当前已验证的面板版本单独设置 3.28 推荐。
3. 保留历史旧面板版本的旧推荐版映射。
4. 在旧客户端的高风险写入口增加 3.28 风险提示。
5. 优先修复 Web `write_openclaw_config()` 的未知字段丢失风险。
6. 再逐步补齐 3.28 的高级 Agent / Session / Tools 能力。

View File

@@ -0,0 +1,368 @@
# OpenClaw 多实例兼容性优化方案
更新时间2026-03-31 01:07:53 +08:00
## 背景
当前 ClawPanel 已经具备“实例切换”的一部分界面与数据结构,但底层仍然以单一 OpenClaw 根目录为默认前提。这会在以下场景中产生明显冲突:
1. 同一台机器存在多个 OpenClaw 安装目录。
2. 用户手动切换了实例,但某些页面仍然读写旧路径。
3. Tauri 桌面端与 Web dev-api 对实例的理解不一致。
4. 多个 OpenClaw 同时运行时Gateway 名称、Bonjour 广播、端口、配置文件读写可能相互干扰。
这个问题不是单个页面写死路径,而是“实例选择层”和“本地路径解析层”没有完成统一抽象。
## 现状诊断
### 1. 现有能力
当前仓库已经有三类相关能力:
1. 面板设置页支持单个自定义 OpenClaw 路径。
2. 前端侧边栏支持实例切换 UI。
3. Web dev-api 具备实例列表、添加、删除、切换能力。
### 2. 当前架构缺口
#### 2.1 单路径配置不等于多实例支持
Tauri 侧当前通过 `clawpanel.json.openclawDir` 决定唯一生效目录,本质上仍然是“全局单路径覆盖”,不是“多实例上下文切换”。
#### 2.2 大量命令直接依赖单一根目录
Rust 侧很多命令直接调用统一的 `openclaw_dir()`,例如:
1. Agent 管理
2. Memory
3. Skills
4. Messaging
5. Service
6. Pairing
7. Config 读写
这意味着只要当前根目录解析不正确,多个页面都会一起读错目录。
#### 2.3 桌面端与 Web 端实例模型不一致
前端 API 里 `instance_*` 被标记为仅 Web 后端实现,说明“实例管理”目前主要停留在 dev-api 层,而桌面端大量本地读写命令仍走 Tauri Rust 本地目录解析。
结果就是:
1. 前端能显示实例切换。
2. 真实文件读写却未必跟随实例切换。
3. 用户会感觉“切了实例,但操作的还是另一个 OpenClaw”。
#### 2.4 本地多实例冲突缺少显式选择
当系统中检测到多个 OpenClaw 安装时当前没有统一的冲突选择弹窗也没有清晰的“当前操作对象是谁”的确认流程。对于会修改配置、插件、Agent 文件的操作,这个缺口风险很高。
## 根因
根因可以归纳为一句话:
> ClawPanel 目前有“实例列表”,但没有“实例上下文驱动的路径解析内核”。
也就是说,实例是 UI 概念,不是系统级资源定位概念。
## 目标
本次优化的目标不是简单把路径输入框改成下拉框,而是建立完整的一套实例上下文机制。
### 功能目标
1. 支持同时管理多个本地 OpenClaw 安装目录。
2. 支持远程实例、Docker 实例、本地实例统一出现在实例中心。
3. 所有本地文件读写类能力都基于“当前激活实例”解析路径。
4. 检测到多个候选 OpenClaw 时,必须弹窗让用户明确选择。
5. 用户可以手动新增、重命名、移除、设为默认本地实例。
6. 高风险操作前能明确显示当前目标实例与路径。
### 体验目标
1. 不允许“静默写错目录”。
2. 不允许“界面切换了实例,后端仍操作旧实例”。
3. 当前激活实例必须在侧边栏、详情页、设置页都可见。
4. 冲突时优先询问用户,不做隐式猜测。
## 设计原则
1. 统一实例抽象,不再区分“本地路径选择”和“实例切换”两套逻辑。
2. 本地实例必须有稳定 ID不能只靠路径字符串临时判断。
3. 路径解析必须收敛到单一入口函数,禁止业务模块自行拼接根目录。
4. 冲突选择必须是显式交互,不能偷偷回退默认目录。
5. Web 模式与桌面模式的数据模型必须一致。
## 数据模型改造
建议将“实例”扩展为统一模型:
```json
{
"activeInstanceId": "local-main",
"instances": [
{
"id": "local-main",
"name": "本机主实例",
"type": "local",
"openclawDir": "C:/Users/user/.openclaw",
"gatewayPort": 18789,
"version": "3.28.0",
"detected": true,
"isDefault": true,
"fingerprint": "sha1:...",
"lastSeenAt": 1774890473
},
{
"id": "local-dev",
"name": "开发实例",
"type": "local",
"openclawDir": "D:/OpenClaw/dev",
"gatewayPort": 28789,
"version": "3.28.0",
"detected": false,
"isDefault": false,
"fingerprint": "sha1:...",
"lastSeenAt": 1774890473
},
{
"id": "remote-xxxx",
"name": "远程节点",
"type": "remote",
"endpoint": "http://192.168.1.8:18789"
}
]
}
```
### 字段说明
1. `id`:稳定实例 ID。
2. `type``local``remote``docker`
3. `openclawDir`:仅本地实例必填。
4. `fingerprint`:用于识别是否是同一个 OpenClaw 实例,避免路径变更后丢失绑定关系。
5. `activeInstanceId`:全局激活实例,不再由单独的 `openclawDir` 决定一切。
## 路径解析内核
### 统一入口
新增统一上下文解析函数:
1. Rust`resolve_active_instance_context()`
2. Node dev-api`resolveActiveInstanceContext()`
返回结构建议为:
```json
{
"id": "local-dev",
"type": "local",
"name": "开发实例",
"openclawDir": "D:/OpenClaw/dev",
"configPath": "D:/OpenClaw/dev/openclaw.json",
"agentsDir": "D:/OpenClaw/dev/agents",
"workspaceDir": "D:/OpenClaw/dev/workspace"
}
```
### 禁止继续直接使用全局根目录
后续所有本地资源读写都应从实例上下文取值,不再在业务代码中直接调用全局默认目录。需要逐步替换以下模式:
1. `openclaw_dir().join("openclaw.json")`
2. `openclaw_dir().join("agents")`
3. `OPENCLAW_DIR + ...`
4. 基于固定 `~/.openclaw` 的路径常量
## 实例发现与冲突检测
### 自动发现来源
建议本地实例发现至少覆盖以下来源:
1. 默认目录:`~/.openclaw`
2. 面板历史记录中的自定义目录
3. 用户手动添加过的目录
4. 最近成功运行 Gateway 的目录
### 判定一个目录是不是有效 OpenClaw
满足以下条件之一即可视为候选实例:
1. 存在 `openclaw.json`
2. 存在 `agents``logs``workspace` 等关键结构
3. 通过读取配置可得到有效 Gateway 配置或版本信息
### 冲突弹窗触发条件
出现以下任一情况时必须弹窗:
1. 启动时发现 2 个及以上本地有效实例,且尚未指定默认实例。
2. 当前默认实例路径不存在,但发现其他可用实例。
3. 用户执行高风险写操作时,当前实例存在歧义。
4. 发现 Bonjour 名称或 Gateway 端口冲突,需要区分具体实例。
## 交互方案
### 1. 启动冲突选择弹窗
当发现多个本地 OpenClaw 时,弹出实例选择框,展示:
1. 实例名称
2. 完整路径
3. OpenClaw 版本
4. Gateway 端口
5. 最近使用时间
6. 配置文件状态
提供按钮:
1. 设为当前实例
2. 设为默认实例
3. 查看详情
4. 手动选择其他目录
### 2. 侧边栏实例切换器增强
当前侧边栏已有实例切换区域,后续应增强为:
1. 本地实例与远程实例分组显示
2. 当前实例显示路径简写
3. 高风险页面顶部显示“当前实例路径”
4. 切换实例后触发全局上下文刷新
### 3. 设置页改造
当前“OpenClaw 安装路径”单输入框应升级为“本地实例管理器”:
1. 列出全部本地实例
2. 支持新增目录
3. 支持校验目录有效性
4. 支持设为默认
5. 支持删除失效记录
## 实施分期
### Phase 1先打通实例上下文内核
目标:所有本地读写命令都能跟随当前实例。
改造项:
1. 定义统一实例模型。
2.`activeInstanceId` 作为全局当前实例标识。
3. 在 Rust 与 dev-api 中新增统一上下文解析函数。
4. Agent、Config、Memory、Skills 先切到新解析层。
### Phase 2补齐实例发现与冲突弹窗
目标:多实例存在时不再隐式写默认目录。
改造项:
1. 启动扫描候选实例。
2. 新增实例冲突弹窗。
3. 新增“记住我的选择”。
4. 启动阶段写入最近使用实例。
### Phase 3统一桌面端与 Web 端实例能力
目标:两套运行模式具有一致的数据模型与行为。
改造项:
1.`instance_*` 能力从仅 Web 实现,补齐到 Tauri 端或抽象成统一后端层。
2. 清理前端 `WEB_ONLY_CMDS` 中与实例管理相关的分支差异。
3. 统一实例切换后的缓存失效与页面刷新策略。
### Phase 4增加保护性提示与审计
目标:降低误操作风险。
改造项:
1. 高风险写操作展示当前实例标识。
2. 写配置前生成实例级备份。
3. 记录最近操作的实例与路径。
## 受影响模块
以下模块需要优先排查和改造:
1. `src-tauri/src/commands/mod.rs`
2. `src-tauri/src/commands/config.rs`
3. `src-tauri/src/commands/agent.rs`
4. `src-tauri/src/commands/memory.rs`
5. `src-tauri/src/commands/skills.rs`
6. `src-tauri/src/commands/messaging.rs`
7. `src-tauri/src/commands/service.rs`
8. `scripts/dev-api.js`
9. `src/lib/tauri-api.js`
10. `src/lib/app-state.js`
11. `src/components/sidebar.js`
12. `src/pages/settings.js`
## 迁移建议
### 配置兼容
旧版本仅有:
```json
{
"openclawDir": "D:/OpenClaw/dev"
}
```
迁移后建议自动转换为:
```json
{
"activeInstanceId": "local-migrated",
"instances": [
{
"id": "local-migrated",
"name": "迁移实例",
"type": "local",
"openclawDir": "D:/OpenClaw/dev",
"isDefault": true
}
]
}
```
### 兼容策略
1. 首次迁移保留旧字段一段时间,只读不再写。
2. 新逻辑优先读取实例模型。
3. 若实例模型缺失,再回退读取旧 `openclawDir`
4. 一旦成功迁移,可在后续版本移除旧字段写入。
## 验收标准
满足以下条件,才算多实例兼容完成:
1. 两个本地 OpenClaw 共存时,用户启动面板会看到明确选择。
2. 切换本地实例后Agent、Config、Skills、Memory、Channels 页面都读写对应实例目录。
3. Tauri 与 Web 模式下,实例切换行为一致。
4. 当前实例信息在 UI 中可见,不存在“我不知道现在在改谁”的状态。
5. 任意高风险写操作都不会静默落到错误目录。
## 明确不建议的方案
以下做法不建议采用:
1. 继续在更多页面增加单独的路径输入框。
2. 只在前端记住当前实例,不改底层路径解析。
3. 发现多个实例时自动猜测“最近修改时间最新的那个”。
4. 仅修补 Agent 页面,不统一 Config、Memory、Skills 等其他模块。
## 建议的下一步落地顺序
1. 先把实例数据模型统一下来。
2. 再实现 Rust 和 dev-api 的上下文解析内核。
3. 然后改 Agent 与 Config 两条主链路做首批验证。
4. 最后补冲突弹窗和设置页实例管理器。
这样可以避免 UI 先做完,底层路径仍然写错的问题再次出现。

View File

@@ -50,6 +50,8 @@ ClawPanel 现在使用仓库根目录的 `openclaw-version-policy.json` 作为
ClawPanel 现在以 `package.json` 作为主版本源,并通过脚本同步到其他文件。
当前 `0.11.0` 起,版本同步脚本也会一并维护 `package-lock.json`,避免 npm 锁文件版本与程序版本漂移。
推荐用法:
```bash
@@ -59,6 +61,7 @@ npm run version:set 0.9.1
这条命令会同步以下文件:
- `package.json`
- `package-lock.json`
- `src-tauri/tauri.conf.json`
- `src-tauri/Cargo.toml`
- `docs/index.html`

View File

@@ -66,7 +66,7 @@
<div class="sp-bar"><div class="sp-bar-inner"></div></div>
<div class="sp-site"><a href="https://qt.cool" target="_blank">qt.cool</a></div>
</div>
<script>window._splashTimer=setTimeout(function(){var s=document.getElementById('splash');if(s){var app=document.getElementById('content');if(app&&app.children.length>0){s.classList.add('hide');setTimeout(function(){s.remove()},500)}else{s.innerHTML='<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif"><div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div><div style="font-size:16px;font-weight:600;color:#18181b;margin-bottom:8px">\u9875\u9762\u52A0\u8F7D\u5931\u8D25</div><div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.6">\u53EF\u80FD\u539F\u56E0\uFF1A\u5E94\u7528\u521D\u59CB\u5316\u8D85\u65F6\uFF0C\u8BF7\u68C0\u67E5\u63A7\u5236\u53F0\u662F\u5426\u6709\u62A5\u9519<br>\u5982\u786E\u8BA4 WebView2 \u672A\u5B89\u88C5\uFF0C\u8BF7\u4E0B\u8F7D <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a></div><button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>'}}},15000)</script>
<script>window._splashTimer=setTimeout(function(){var s=document.getElementById('splash');if(s){var app=document.getElementById('content');if(app&&app.children.length>0){s.classList.add('hide');setTimeout(function(){s.remove()},500)}else{s.innerHTML='<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif"><div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div><div style="font-size:16px;font-weight:600;color:#18181b;margin-bottom:8px">\u9875\u9762\u52A0\u8F7D\u5931\u8D25</div><div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.7">\u5E94\u7528\u542F\u52A8\u9636\u6BB5\u6CA1\u6709\u5B8C\u6210\u524D\u7AEF\u52A0\u8F7D\u3002\u8FD9\u901A\u5E38\u610F\u5473\u7740 WebView2 \u672A\u5B89\u88C5/\u5DF2\u635F\u574F\uFF0C\u6216\u8005\u524D\u7AEF\u8D44\u6E90\u88AB\u62E6\u622A\u3002<br>\u8BF7\u5148\u68C0\u67E5\u63A7\u5236\u53F0\u662F\u5426\u6709\u62A5\u9519\uFF1B\u5982\u786E\u8BA4 WebView2 \u672A\u5B89\u88C5\uFF0C\u8BF7\u4E0B\u8F7D <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a></div><button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>'}}},30000)</script>
<div id="app">
<aside id="sidebar"></aside>

View File

@@ -98,11 +98,20 @@
},
"0.10.0": {
"official": {
"recommended": "2026.3.13"
"recommended": "2026.3.28"
},
"chinese": {
"recommended": "2026.3.13-zh.1"
"recommended": "2026.3.28-zh.2"
}
},
"0.11.0": {
"official": {
"recommended": "2026.3.28"
},
"chinese": {
"recommended": "2026.3.28-zh.2"
}
}
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "clawpanel",
"version": "0.9.9",
"version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clawpanel",
"version": "0.9.9",
"version": "0.11.0",
"license": "AGPL-3.0",
"dependencies": {
"@tauri-apps/api": "^2.5.0",

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.10.0",
"version": "0.11.0",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,13 @@ const root = resolve(__dirname, '..')
// 读取 package.json
const pkgPath = resolve(root, 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const cargoPackageName = readFileSync(resolve(root, 'src-tauri/Cargo.toml'), 'utf8')
.match(/\[package\][\s\S]*?^name\s*=\s*"([^"]+)"/m)?.[1]
if (!cargoPackageName) {
console.error('❌ src-tauri/Cargo.toml: 找不到 [package].name')
process.exit(1)
}
// 如果传入了新版本号,先更新 package.json
const newVersion = process.argv[2]
@@ -32,6 +39,10 @@ if (newVersion) {
const version = pkg.version
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// 同步目标文件
const targets = [
{
@@ -42,12 +53,33 @@ const targets = [
return JSON.stringify(obj, null, 2) + '\n'
},
},
{
file: 'package-lock.json',
update(content) {
const obj = JSON.parse(content)
obj.version = version
if (obj.packages && obj.packages['']) {
obj.packages[''].version = version
}
return JSON.stringify(obj, null, 2) + '\n'
},
},
{
file: 'src-tauri/Cargo.toml',
update(content) {
return content.replace(/^version\s*=\s*"[^"]*"/m, `version = "${version}"`)
},
},
{
file: 'src-tauri/Cargo.lock',
update(content) {
const pattern = new RegExp(`(\\[\\[package\\]\\]\\r?\\nname = "${escapeRegExp(cargoPackageName)}"\\r?\\nversion = ")[^"]*(")`)
if (!pattern.test(content)) {
throw new Error(`未找到 ${cargoPackageName} 的锁文件条目`)
}
return content.replace(pattern, `$1${version}$2`)
},
},
{
file: 'docs/index.html',
update(content) {

2
src-tauri/Cargo.lock generated
View File

@@ -351,7 +351,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.10.0"
version = "0.11.0"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -1,10 +1,22 @@
/// Agent 管理命令 — 列表/改名直接读写 openclaw.json创建/删除走 CLI需要创建 workspace 等文件)
use crate::utils::openclaw_command_async;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::Value;
use std::fs;
use std::io::Write;
const AGENT_FILE_ALLOWLIST: &[&str] = &[
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
];
/// Workspace 状态信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceStatus {
@@ -210,6 +222,200 @@ pub async fn list_agents() -> Result<Value, String> {
Ok(Value::Array(enriched))
}
#[tauri::command]
pub async fn get_agent_detail(id: String) -> Result<Value, String> {
let config_path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
let config: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
let defaults = config
.get("agents")
.and_then(|a| a.get("defaults"))
.cloned()
.unwrap_or(Value::Null);
let bindings = config
.get("bindings")
.and_then(|b| b.as_array())
.cloned()
.unwrap_or_default();
let mut agent = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))
.cloned()
})
.unwrap_or_else(|| json!({ "id": id.clone(), "default": id == "main" }));
let workspace = agent
.get("workspace")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| resolve_agent_workspace(&id, &config));
let agent_bindings: Vec<Value> = bindings
.into_iter()
.filter(|b| b.get("agentId").and_then(|v| v.as_str()).unwrap_or("main") == id)
.collect();
let is_default = agent
.get("default")
.and_then(|v| v.as_bool())
.unwrap_or(id == "main");
agent.as_object_mut().map(|obj| {
obj.insert("workspace".to_string(), Value::String(workspace));
obj.insert("bindings".to_string(), Value::Array(agent_bindings));
obj.insert("isDefault".to_string(), Value::Bool(is_default));
obj.insert("defaults".to_string(), defaults);
});
Ok(agent)
}
#[tauri::command]
pub async fn list_agent_files(id: String) -> Result<Value, String> {
let config = read_openclaw_config_value()?;
let agent_dir = resolve_agent_dir(&id, &config);
let files: Vec<Value> = AGENT_FILE_ALLOWLIST
.iter()
.map(|name| {
let path = agent_dir.join(name);
let meta = fs::metadata(&path).ok();
json!({
"name": name,
"desc": bootstrap_file_desc(name),
"exists": path.exists(),
"size": meta.as_ref().map(|m| m.len()).unwrap_or(0),
"mtime": meta.and_then(|m| m.modified().ok()).and_then(|m| chrono::DateTime::<chrono::Utc>::from(m).to_rfc3339().into()),
"path": path.to_string_lossy().to_string(),
})
})
.collect();
Ok(Value::Array(files))
}
#[tauri::command]
pub async fn read_agent_file(id: String, name: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = read_openclaw_config_value()?;
let path = resolve_agent_dir(&id, &config).join(&name);
if !path.exists() {
return Ok(json!({ "exists": false, "content": "" }));
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取文件失败: {e}"))?;
Ok(json!({ "exists": true, "content": content }))
}
#[tauri::command]
pub async fn write_agent_file(id: String, name: String, content: String) -> Result<Value, String> {
ensure_allowed_agent_file(&name)?;
let config = read_openclaw_config_value()?;
let dir = resolve_agent_dir(&id, &config);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
fs::write(dir.join(&name), content).map_err(|e| format!("写入文件失败: {e}"))?;
Ok(json!({ "ok": true }))
}
#[tauri::command]
pub async fn update_agent_config(
app: tauri::AppHandle,
id: String,
config: Value,
) -> Result<Value, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let mut root: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?;
if root.get("agents").is_none() {
root.as_object_mut()
.ok_or("配置格式错误")?
.insert("agents".to_string(), json!({}));
}
if root["agents"].get("list").is_none() {
root["agents"]
.as_object_mut()
.ok_or("agents 格式错误")?
.insert("list".to_string(), json!([]));
}
let list = root["agents"]["list"]
.as_array_mut()
.ok_or("agents.list 格式错误")?;
let index = list
.iter()
.position(|agent| agent.get("id").and_then(|v| v.as_str()) == Some(id.as_str()));
let idx = match index {
Some(idx) => idx,
None if id == "main" => {
list.insert(0, json!({ "id": "main" }));
0
}
None => return Err(format!("Agent「{id}」不存在")),
};
let agent = list[idx].as_object_mut().ok_or("Agent 格式错误")?;
if let Some(identity) = config.get("identity").and_then(|v| v.as_object()) {
let identity_obj = agent.entry("identity".to_string()).or_insert_with(|| json!({}));
let identity_obj = identity_obj.as_object_mut().ok_or("identity 格式错误")?;
if let Some(name) = identity.get("name") {
if name.is_null() {
identity_obj.remove("name");
} else {
identity_obj.insert("name".to_string(), name.clone());
}
}
if let Some(emoji) = identity.get("emoji") {
if emoji.is_null() {
identity_obj.remove("emoji");
} else {
identity_obj.insert("emoji".to_string(), emoji.clone());
}
}
}
if let Some(model) = config.get("model") {
if model.is_null() {
agent.remove("model");
} else {
agent.insert("model".to_string(), model.clone());
}
}
if let Some(thinking) = config.get("thinkingDefault") {
if thinking.is_null() {
agent.remove("thinkingDefault");
} else {
agent.insert("thinkingDefault".to_string(), thinking.clone());
}
}
if let Some(skills) = config.get("skills") {
if skills.is_null() {
agent.remove("skills");
} else {
agent.insert("skills".to_string(), skills.clone());
}
}
if let Some(tools) = config.get("tools") {
if tools.is_null() {
agent.remove("tools");
} else {
agent.insert("tools".to_string(), tools.clone());
}
}
let json_text = serde_json::to_string_pretty(&root).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json_text).map_err(|e| format!("写入配置失败: {e}"))?;
let _ = super::config::do_reload_gateway(&app).await;
Ok(json!({ "ok": true }))
}
/// 创建新 agent优先走 CLI失败则直接写 openclaw.json 兜底)
#[tauri::command]
pub async fn add_agent(
@@ -596,3 +802,81 @@ pub async fn update_agent_model(
Ok("已更新".into())
}
fn read_openclaw_config_value() -> Result<Value, String> {
let path = super::openclaw_dir().join("openclaw.json");
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))
}
fn resolve_agent_workspace(id: &str, config: &Value) -> String {
config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
.and_then(|a| a.get("workspace"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir()
.join("workspace")
.to_string_lossy()
.to_string()
} else {
super::openclaw_dir()
.join("agents")
.join(id)
.join("workspace")
.to_string_lossy()
.to_string()
}
})
}
fn resolve_agent_dir(id: &str, config: &Value) -> std::path::PathBuf {
let custom_dir = config
.get("agents")
.and_then(|a| a.get("list"))
.and_then(|l| l.as_array())
.and_then(|list| {
list.iter()
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(id))
.and_then(|a| a.get("agentDir"))
.and_then(|v| v.as_str())
.map(|s| std::path::PathBuf::from(s))
});
custom_dir.unwrap_or_else(|| {
if id == "main" {
super::openclaw_dir()
} else {
super::openclaw_dir().join("agents").join(id)
}
})
}
fn ensure_allowed_agent_file(name: &str) -> Result<(), String> {
if AGENT_FILE_ALLOWLIST.contains(&name) {
Ok(())
} else {
Err("不允许访问此文件".to_string())
}
}
fn bootstrap_file_desc(name: &str) -> &'static str {
match name {
"AGENTS.md" => "Agent 规则",
"SOUL.md" => "灵魂/人格",
"TOOLS.md" => "工具白名单",
"IDENTITY.md" => "身份信息",
"USER.md" => "用户上下文",
"HEARTBEAT.md" => "心跳指令",
"BOOTSTRAP.md" => "初始化引导",
"MEMORY.md" => "记忆存储",
_ => "",
}
}

View File

@@ -380,8 +380,7 @@ fn pre_install_cleanup() {
// 2. 清理 npm 全局 bin 目录下的 openclaw 残留文件Windows EEXIST 根因)
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_bin = std::path::Path::new(&appdata).join("npm");
if let Some(npm_bin) = npm_global_bin_dir() {
for name in &["openclaw", "openclaw.cmd", "openclaw.ps1"] {
let p = npm_bin.join(name);
if p.exists() {
@@ -418,7 +417,7 @@ pub fn read_openclaw_config() -> Result<Value, String> {
v
}
Err(e) => {
// JSON 解析失败,尝试自动修复常见错误
// JSON 解析失败,尝试自动修复
let fixed_content = fix_common_json_errors(&content);
if let Ok(v) = serde_json::from_str(&fixed_content) {
eprintln!("自动修复了配置文件的 JSON 语法错误");
@@ -706,49 +705,47 @@ pub fn validate_openclaw_config() -> Result<Value, String> {
// 尝试解析 JSON
let config: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Ok(v) => {
// BOM 被剥离过,静默写回干净文件
if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
let _ = fs::write(&path, &content);
}
v
}
Err(e) => {
// JSON 解析失败,尝试自动修复
let fixed_content = fix_common_json_errors(&content);
match serde_json::from_str::<Value>(&fixed_content) {
Ok(_v) => {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 有语法错误,但已自动修复 (行: {}, 列: {})", e.line(), e.column()),
"auto_fixed": true,
"warnings": [
"配置文件存在 JSON 语法错误,已自动修复",
"建议:检查配置文件是否有尾随逗号或注释"
]
}));
}
Err(_) => {
// 自动修复失败,检查备份
let bak = super::openclaw_dir().join("openclaw.json.bak");
if bak.exists() {
if let Ok(bak_content) = fs::read_to_string(&bak) {
if serde_json::from_str::<Value>(&bak_content).is_ok() {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}), 建议从备份恢复", e.line(), e.column()),
"backup_exists": true,
"warnings": [
"配置文件损坏,建议使用备份恢复",
"备份文件openclaw.json.bak"
]
}));
}
if let Ok(v) = serde_json::from_str(&fixed_content) {
eprintln!("自动修复了配置文件的 JSON 语法错误");
// 写回修复后的配置
let _ = fs::write(&path, &fixed_content);
v
} else {
// 自动修复失败,尝试从备份恢复
let bak = super::openclaw_dir().join("openclaw.json.bak");
if bak.exists() {
if let Ok(bak_content) = fs::read_to_string(&bak) {
if serde_json::from_str::<Value>(&bak_content).is_ok() {
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}), 建议从备份恢复", e.line(), e.column()),
"backup_exists": true,
"warnings": [
"配置文件损坏,建议使用备份恢复",
"备份文件openclaw.json.bak"
]
}));
}
}
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}): {}", e.line(), e.column(), e),
"warnings": [
"配置文件严重损坏且无有效备份",
"建议:手动检查或重新创建配置文件"
]
}));
}
return Ok(json!({
"config_valid": false,
"json_error": format!("JSON 解析失败 (行: {}, 列: {}): {}", e.line(), e.column(), e),
"warnings": [
"配置文件严重损坏且无有效备份",
"建议:手动检查或重新创建配置文件"
]
}));
}
}
};
@@ -872,7 +869,7 @@ pub fn validate_openclaw_config() -> Result<Value, String> {
}
/// 将 openclaw.json 的 models.providers 完整同步到每个 agent 的 models.json
/// 包括:同步 baseUrl/apiKey/api、删除已移除的 provider、删除已移除的 model
/// 包括:同步 baseUrl/apiKey/api + 清理已删除的 models
/// 确保 Gateway 运行时不会引用 openclaw.json 中已不存在的模型
fn sync_providers_to_agent_models(config: &Value) {
let src_providers = config
@@ -1236,8 +1233,7 @@ async fn get_local_version() -> Option<String> {
}
}
if let Ok(appdata) = std::env::var("APPDATA") {
let npm_bin = PathBuf::from(&appdata).join("npm");
if let Some(npm_bin) = npm_global_bin_dir() {
let shim_path = npm_bin.join("openclaw.cmd");
// 仅当 npm 全局 CLI shim 存在时才读取版本
if !shim_path.exists() {
@@ -1282,32 +1278,8 @@ async fn get_local_version() -> Option<String> {
}
// 2. standalone 目录
for sa_dir in all_standalone_dirs() {
if !sa_dir.join("openclaw").exists() {
continue;
}
let version_file = sa_dir.join("VERSION");
if let Ok(content) = fs::read_to_string(&version_file) {
for line in content.lines() {
if let Some(ver) = line.strip_prefix("openclaw_version=") {
let ver = ver.trim();
if !ver.is_empty() {
return Some(ver.to_string());
}
}
}
}
let sa_pkg = sa_dir
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh")
.join("package.json");
if let Ok(content) = fs::read_to_string(&sa_pkg) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
if sa_dir.join("openclaw").exists() || sa_dir.join("VERSION").exists() {
return Some("unknown".to_string());
}
}
// 3. symlink -> package.json
@@ -1453,8 +1425,8 @@ fn detect_installed_source() -> String {
}
}
// 无活跃 CLI 时的兜底:仅检查 npm 全局目录中实际存在的 shim
if let Ok(appdata) = std::env::var("APPDATA") {
let shim = PathBuf::from(&appdata).join("npm").join("openclaw.cmd");
if let Some(npm_bin) = npm_global_bin_dir() {
let shim = npm_bin.join("openclaw.cmd");
if let Some(s) = detect_source_from_cmd_shim(&shim) {
return s;
}
@@ -1575,6 +1547,31 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
})
}
fn scan_cli_identity(cli_path: &std::path::Path) -> String {
let mut identity_path = cli_path.to_path_buf();
#[cfg(target_os = "windows")]
{
let file_name = cli_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(file_name.as_str(), "openclaw" | "openclaw.exe" | "openclaw.ps1") {
let cmd_path = cli_path.with_file_name("openclaw.cmd");
if cmd_path.exists() {
identity_path = cmd_path;
}
}
}
identity_path
.canonicalize()
.unwrap_or(identity_path)
.to_string_lossy()
.to_lowercase()
}
/// 扫描系统中所有可检测到的 OpenClaw 安装
fn scan_all_installations(
active_path: &Option<String>,
@@ -1582,33 +1579,28 @@ fn scan_all_installations(
use crate::models::types::OpenClawInstallation;
let mut results: Vec<OpenClawInstallation> = Vec::new();
let mut seen = std::collections::HashSet::new();
let active_identity = active_path
.as_ref()
.map(|path| scan_cli_identity(std::path::Path::new(path)));
let mut try_add = |path: std::path::PathBuf| {
if !path.exists() {
return;
}
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.clone())
.to_string_lossy()
.to_string();
if seen.contains(&canonical) {
if crate::utils::is_rejected_cli_path(&path.to_string_lossy()) {
return;
}
seen.insert(canonical.clone());
let identity = scan_cli_identity(&path);
if seen.contains(&identity) {
return;
}
seen.insert(identity.clone());
let path_str = path.to_string_lossy().to_string();
let source = crate::utils::classify_cli_source(&path_str);
let version = read_version_from_installation(&path);
let is_active = active_path
let is_active = active_identity
.as_ref()
.map(|a| {
let a_canon = std::path::Path::new(a)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(a))
.to_string_lossy()
.to_string();
a_canon == canonical
})
.map(|active| active == &identity)
.unwrap_or(false);
results.push(OpenClawInstallation {
path: path_str,
@@ -1621,12 +1613,22 @@ fn scan_all_installations(
// standalone 安装目录
for sa_dir in all_standalone_dirs() {
#[cfg(target_os = "windows")]
try_add(sa_dir.join("openclaw.cmd"));
{
try_add(sa_dir.join("openclaw.cmd"));
try_add(sa_dir.join("openclaw.exe"));
}
#[cfg(not(target_os = "windows"))]
try_add(sa_dir.join("openclaw"));
{
try_add(sa_dir.join("openclaw"));
}
}
for configured in super::openclaw_search_paths() {
if let Some(resolved) = resolve_openclaw_cli_input_path(&configured) {
try_add(resolved);
}
}
// npm 全局目录
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
@@ -1635,10 +1637,106 @@ fn scan_all_installations(
.join("npm")
.join("openclaw.cmd"),
);
try_add(
std::path::PathBuf::from(&appdata)
.join("npm")
.join("openclaw"),
);
}
if let Some(prefix) = super::windows_npm_global_prefix() {
let prefix_path = std::path::PathBuf::from(prefix);
try_add(prefix_path.join("openclaw.cmd"));
try_add(prefix_path.join("openclaw.exe"));
try_add(prefix_path.join("openclaw"));
}
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
try_add(
std::path::PathBuf::from(&localappdata)
.join("Programs")
.join("nodejs")
.join("openclaw.cmd"),
);
}
if let Ok(program_files) = std::env::var("ProgramFiles") {
try_add(
std::path::PathBuf::from(&program_files)
.join("nodejs")
.join("openclaw.cmd"),
);
try_add(
std::path::PathBuf::from(&program_files)
.join("OpenClaw")
.join("openclaw.cmd"),
);
}
if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") {
try_add(
std::path::PathBuf::from(&program_files_x86)
.join("nodejs")
.join("openclaw.cmd"),
);
}
if let Ok(profile) = std::env::var("USERPROFILE") {
try_add(
std::path::PathBuf::from(&profile)
.join(".openclaw-bin")
.join("openclaw.cmd"),
);
}
for drive in ["C", "D", "E", "F", "G"] {
try_add(std::path::PathBuf::from(format!(
r"{}:\OpenClaw\openclaw.cmd",
drive
)));
try_add(std::path::PathBuf::from(format!(
r"{}:\AI\OpenClaw\openclaw.cmd",
drive
)));
}
let mut where_cmd = Command::new("where");
where_cmd.arg("openclaw");
where_cmd.creation_flags(0x08000000);
if let Ok(output) = where_cmd.output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
try_add(std::path::PathBuf::from(trimmed));
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = dirs::home_dir() {
try_add(home.join(".npm-global").join("bin").join("openclaw"));
try_add(home.join(".local").join("bin").join("openclaw"));
try_add(home.join(".nvm").join("current").join("bin").join("openclaw"));
try_add(home.join(".volta").join("bin").join("openclaw"));
try_add(home.join(".fnm").join("current").join("bin").join("openclaw"));
try_add(home.join("bin").join("openclaw"));
}
try_add(std::path::PathBuf::from("/opt/openclaw/openclaw"));
try_add(std::path::PathBuf::from("/opt/homebrew/bin/openclaw"));
try_add(std::path::PathBuf::from("/usr/local/bin/openclaw"));
try_add(std::path::PathBuf::from("/usr/bin/openclaw"));
try_add(std::path::PathBuf::from("/snap/bin/openclaw"));
if let Ok(output) = Command::new("which").args(["-a", "openclaw"]).output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
try_add(std::path::PathBuf::from(trimmed));
}
}
}
}
// PATH 中找到的所有 openclaw
let enhanced = super::enhanced_path();
#[cfg(target_os = "windows")]
let sep = ';';
@@ -1660,9 +1758,119 @@ fn scan_all_installations(
}
}
results.sort_by(|a, b| {
b.active
.cmp(&a.active)
.then_with(|| a.source.cmp(&b.source))
.then_with(|| a.path.cmp(&b.path))
});
results
}
pub(crate) fn resolve_openclaw_cli_input_path(
cli_path: &std::path::Path,
) -> Option<std::path::PathBuf> {
if cli_path.as_os_str().is_empty() {
return None;
}
let input = cli_path.to_path_buf();
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if input.is_dir() {
#[cfg(target_os = "windows")]
{
candidates.push(input.join("openclaw.cmd"));
candidates.push(input.join("openclaw.exe"));
candidates.push(input.join("openclaw"));
}
#[cfg(not(target_os = "windows"))]
{
candidates.push(input.join("openclaw"));
}
} else {
candidates.push(input);
}
candidates.into_iter().find(|candidate| {
candidate.exists() && !crate::utils::is_rejected_cli_path(&candidate.to_string_lossy())
})
}
pub(crate) fn resolve_openclaw_cli_input(cli_path: &str) -> Option<std::path::PathBuf> {
let raw = cli_path.trim();
if raw.is_empty() {
return None;
}
resolve_openclaw_cli_input_path(std::path::Path::new(raw))
}
#[tauri::command]
pub fn scan_openclaw_paths() -> Result<Vec<crate::models::types::OpenClawInstallation>, String> {
super::refresh_enhanced_path();
crate::commands::service::invalidate_cli_detection_cache();
let active_path = crate::utils::resolve_openclaw_cli_path();
Ok(scan_all_installations(&active_path))
}
#[tauri::command]
pub fn check_openclaw_at_path(cli_path: String) -> Result<Value, String> {
let mut result = serde_json::Map::new();
if let Some(resolved) = resolve_openclaw_cli_input(&cli_path) {
let path_str = resolved.to_string_lossy().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("path".into(), Value::String(path_str.clone()));
result.insert(
"source".into(),
Value::String(crate::utils::classify_cli_source(&path_str)),
);
if let Some(version) = read_version_from_installation(&resolved) {
result.insert("version".into(), Value::String(version));
} else {
result.insert("version".into(), Value::Null);
}
} else {
result.insert("installed".into(), Value::Bool(false));
result.insert("path".into(), Value::Null);
result.insert("source".into(), Value::Null);
result.insert("version".into(), Value::Null);
}
Ok(Value::Object(result))
}
fn find_git_path() -> Option<String> {
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("where");
cmd.arg("git");
cmd.creation_flags(0x08000000);
if let Ok(output) = cmd.output() {
if output.status.success() {
if let Some(first_line) = String::from_utf8_lossy(&output.stdout).lines().next() {
let path = first_line.trim().to_string();
if !path.is_empty() && std::path::Path::new(&path).exists() {
return Some(path);
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(output) = Command::new("which").arg("git").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && std::path::Path::new(&path).exists() {
return Some(path);
}
}
}
}
None
}
/// 从安装路径附近读取版本信息
fn read_version_from_installation(cli_path: &std::path::Path) -> Option<String> {
// 尝试从同目录的 VERSION 文件读取
@@ -1864,9 +2072,13 @@ fn r2_platform_key() -> &'static str {
fn npm_global_modules_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
super::windows_npm_global_prefix()
.map(|prefix| PathBuf::from(prefix).join("node_modules"))
.or_else(|| {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm").join("node_modules"))
})
}
#[cfg(target_os = "macos")]
{
@@ -1902,9 +2114,9 @@ fn npm_global_modules_dir() -> Option<PathBuf> {
fn npm_global_bin_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("npm"))
super::windows_npm_global_prefix()
.map(PathBuf::from)
.or_else(|| std::env::var("APPDATA").ok().map(|a| PathBuf::from(a).join("npm")))
}
#[cfg(target_os = "macos")]
{
@@ -3334,6 +3546,9 @@ pub fn scan_node_paths() -> Result<Value, String> {
if !appdata.is_empty() {
candidates.push((format!(r"{}\npm", appdata), "NPM_GLOBAL".to_string()));
}
if let Some(prefix) = super::windows_npm_global_prefix() {
candidates.push((prefix, "NPM_GLOBAL".to_string()));
}
// 系统默认
candidates.push((format!(r"{}\nodejs", pf), "SYSTEM".to_string()));
@@ -3467,7 +3682,10 @@ fn is_nvm_active_version(nvm_dir: &str, version_dir: &std::path::Path) -> bool {
/// 保存用户自定义的 Node.js 路径到 ~/.openclaw/clawpanel.json
#[tauri::command]
pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
let config_path = super::openclaw_dir().join("clawpanel.json");
let config_path = super::panel_config_path();
if let Some(parent) = config_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut config: serde_json::Map<String, Value> = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?;
@@ -3496,7 +3714,10 @@ pub fn write_env_file(path: String, config: String) -> Result<(), String> {
// 安全限制:只允许写入 ~/.openclaw/ 目录下的文件
let openclaw_base = super::openclaw_dir();
if !expanded.starts_with(&openclaw_base) {
return Err("只允许写入 ~/.openclaw/ 目录下的文件".to_string());
return Err(format!(
"只允许写入 {} 目录下的文件",
openclaw_base.display()
));
}
if let Some(parent) = expanded.parent() {
@@ -4290,7 +4511,7 @@ pub fn get_openclaw_dir() -> Result<Value, String> {
let resolved = super::openclaw_dir();
let is_custom = super::read_panel_config_value()
.and_then(|v| v.get("openclawDir")?.as_str().map(String::from))
.map(|s| !s.is_empty())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let config_exists = resolved.join("openclaw.json").exists();
Ok(json!({
@@ -4380,6 +4601,7 @@ pub fn set_npm_registry(registry: String) -> Result<(), String> {
#[tauri::command]
pub fn check_git() -> Result<Value, String> {
let mut result = serde_json::Map::new();
let git_path = find_git_path();
let mut cmd = Command::new("git");
cmd.arg("--version");
#[cfg(target_os = "windows")]
@@ -4389,10 +4611,17 @@ pub fn check_git() -> Result<Value, String> {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
result.insert(
"path".into(),
git_path
.map(Value::String)
.unwrap_or(Value::Null),
);
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
result.insert("path".into(), Value::Null);
}
}
Ok(Value::Object(result))

View File

@@ -120,6 +120,128 @@ fn put_csv_array_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str)
}
}
fn normalize_binding_match_value(value: &Value) -> Option<Value> {
match value {
Value::Null => None,
Value::String(s) => Some(Value::String(s.trim().to_string())),
Value::Array(items) => {
let mut normalized: Vec<Value> = items
.iter()
.filter_map(normalize_binding_match_value)
.collect();
if normalized.iter().all(|item| item.as_str().is_some()) {
normalized.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
}
Some(Value::Array(normalized))
}
Value::Object(map) => {
let mut result = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
let Some(item) = map.get(key) else {
continue;
};
if key == "peer" {
if let Some(peer_id) = item.as_str().map(str::trim).filter(|s| !s.is_empty()) {
result.insert("peer".into(), json!({ "kind": "direct", "id": peer_id }));
} else if let Some(peer_obj) = item.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
result.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
continue;
}
let Some(normalized) = normalize_binding_match_value(item) else {
continue;
};
if key == "accountId"
&& normalized.as_str().map(|s| s.is_empty()).unwrap_or(false)
{
continue;
}
if normalized.as_str().map(|s| s.is_empty()).unwrap_or(false) {
continue;
}
result.insert(key.clone(), normalized);
}
Some(Value::Object(result))
}
_ => Some(value.clone()),
}
}
fn build_binding_match(channel: &str, account_id: Option<&str>, binding_config: &Value) -> Value {
let mut match_config = Map::new();
match_config.insert("channel".into(), Value::String(channel.to_string()));
if let Some(acct) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("accountId".into(), Value::String(acct.to_string()));
}
if let Some(config_obj) = binding_config.as_object() {
for (k, v) in config_obj {
if k == "peer" {
if let Some(peer_str) = v.as_str().map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("peer".into(), json!({ "kind": "direct", "id": peer_str }));
} else if let Some(peer_obj) = v.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
match_config.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
} else if k != "accountId" && k != "channel" && !v.is_null() {
match_config.insert(k.clone(), v.clone());
}
}
}
normalize_binding_match_value(&Value::Object(match_config))
.unwrap_or_else(|| Value::Object(Map::new()))
}
fn binding_identity_matches(binding: &Value, agent_id: &str, target_match: &Value) -> bool {
let binding_agent = binding
.get("agentId")
.and_then(|v| v.as_str())
.unwrap_or("main");
if binding_agent != agent_id {
return false;
}
let existing_match = normalize_binding_match_value(binding.get("match").unwrap_or(&Value::Null))
.unwrap_or_else(|| Value::Object(Map::new()));
let expected_match = normalize_binding_match_value(target_match)
.unwrap_or_else(|| Value::Object(Map::new()));
existing_match == expected_match
}
fn gateway_auth_mode(cfg: &Value) -> Option<&str> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
@@ -3237,81 +3359,19 @@ pub async fn save_agent_binding(
serde_json::Value::String(agent_id.clone()),
);
// 构建 match 配置
let mut match_config = serde_json::Map::new();
match_config.insert(
"channel".to_string(),
serde_json::Value::String(channel.clone()),
);
if let Some(ref acct) = account_id {
if !acct.is_empty() {
match_config.insert(
"accountId".to_string(),
serde_json::Value::String(acct.clone()),
);
}
}
let target_match = build_binding_match(&channel, account_id.as_deref(), &binding_config);
// 合并用户提供的配置到 match 中
if let Some(config_obj) = binding_config.as_object() {
for (k, v) in config_obj {
if k == "peer" {
// peer 写入 match.peerOpenClaw schema 要求)
if let Some(peer_str) = v.as_str().filter(|s| !s.is_empty()) {
match_config.insert(
"peer".to_string(),
serde_json::json!({ "kind": "direct", "id": peer_str }),
);
} else if let Some(peer_obj) = v.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
if let Some(id_val) = id {
match_config.insert(
"peer".to_string(),
serde_json::json!({ "kind": kind, "id": id_val }),
);
}
}
} else if k == "accountId" || k == "channel" {
// 这两个已有专门逻辑处理,跳过
} else {
match_config.insert(k.clone(), v.clone());
}
}
}
new_binding.insert("match".to_string(), serde_json::Value::Object(match_config));
new_binding.insert("match".to_string(), target_match.clone());
// 先转换为 Value避免在循环中移动
let binding_value = serde_json::Value::Object(new_binding);
// 检查是否已存在相同 agentId + channel + accountId 的绑定,如有则更新
let mut found = false;
for binding in bindings_arr.iter_mut() {
if let (Some(existing_agent), Some(existing_channel), Some(existing_match)) = (
binding.get("agentId").and_then(|v| v.as_str()),
binding
.get("match")
.and_then(|m| m.get("channel"))
.and_then(|v| v.as_str()),
binding.get("match"),
) {
if existing_agent == agent_id && existing_channel == channel {
// 检查 accountId 是否匹配
let existing_account = existing_match.get("accountId").and_then(|v| v.as_str());
if existing_account == account_id.as_deref() {
*binding = binding_value.clone();
found = true;
break;
}
}
if binding_identity_matches(binding, &agent_id, &target_match) {
*binding = binding_value.clone();
found = true;
break;
}
}
@@ -3343,62 +3403,22 @@ pub async fn delete_agent_binding(
agent_id: String,
channel: String,
account_id: Option<String>,
binding_config: Option<serde_json::Value>,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let target_match = build_binding_match(
&channel,
account_id.as_deref(),
binding_config.as_ref().unwrap_or(&Value::Null),
);
let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) else {
return Ok(serde_json::json!({ "ok": true }));
};
let original_len = bindings.len();
bindings.retain(|b| {
// 检查是否是该 agent 的绑定
if b.get("agentId")
.and_then(|v| v.as_str())
.map(|id| id != agent_id)
.unwrap_or(true)
{
return true; // 保留非该 agent 的绑定
}
// 检查 channel 是否匹配
let match_obj = match b.get("match").and_then(|m| m.as_object()) {
Some(m) => m,
None => return true, // 保留无效格式
};
let binding_channel = match_obj.get("channel").and_then(|v| v.as_str());
if binding_channel != Some(&channel) {
return true; // 保留不匹配 channel 的绑定
}
let binding_acct = match_obj
.get("accountId")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
match account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(acct) => {
if binding_acct != Some(acct) {
return true;
}
}
None => {
// 未指定 account只删默认绑定无 accountId 或空)
if binding_acct.is_some() {
return true;
}
}
}
false // 删除这个绑定
});
bindings.retain(|b| !binding_identity_matches(b, &agent_id, &target_match));
let removed = original_len - bindings.len();
if removed == 0 {

View File

@@ -1,5 +1,9 @@
use std::net::IpAddr;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use std::process::Command;
use std::sync::RwLock;
use std::time::Duration;
@@ -27,20 +31,55 @@ fn default_openclaw_dir() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".openclaw")
}
fn normalize_custom_openclaw_dir(raw: &str) -> Option<PathBuf> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let expanded = if let Some(rest) = trimmed
.strip_prefix("~/")
.or_else(|| trimmed.strip_prefix("~\\"))
{
dirs::home_dir().unwrap_or_default().join(rest)
} else {
PathBuf::from(trimmed)
};
if expanded.is_absolute() {
Some(expanded)
} else {
std::env::current_dir().ok().map(|cwd| cwd.join(expanded))
}
}
pub fn openclaw_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let Some(value) = read_panel_config_value() else {
return paths;
};
let Some(entries) = value.get("openclawSearchPaths").and_then(|v| v.as_array()) else {
return paths;
};
for raw in entries.iter().filter_map(|v| v.as_str()) {
if let Some(path) = normalize_custom_openclaw_dir(raw) {
if !paths.iter().any(|p| p == &path) {
paths.push(path);
}
}
}
paths
}
/// 获取 OpenClaw 配置目录
/// 优先使用 clawpanel.json 中的 openclawDir 自定义路径,不存在则回退默认 ~/.openclaw
pub fn openclaw_dir() -> PathBuf {
// 直接读 clawpanel.json始终在默认目录下避免循环依赖
let config_path = default_openclaw_dir().join("clawpanel.json");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(custom) = v.get("openclawDir").and_then(|d| d.as_str()) {
let p = PathBuf::from(custom);
if !custom.is_empty() && p.exists() {
return p;
}
}
}
if let Some(custom) = read_panel_config_value()
.and_then(|v| v.get("openclawDir")?.as_str().map(String::from))
.and_then(|v| normalize_custom_openclaw_dir(&v))
{
return custom;
}
default_openclaw_dir()
}
@@ -85,6 +124,31 @@ fn panel_config_path() -> PathBuf {
default_openclaw_dir().join("clawpanel.json")
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_npm_global_prefix() -> Option<String> {
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
let trimmed = prefix.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut cmd = Command::new("cmd");
cmd.args(["/d", "/s", "/c", "npm config get prefix"]);
cmd.creation_flags(CREATE_NO_WINDOW);
if let Ok(output) = cmd.output() {
if output.status.success() {
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !prefix.is_empty() && prefix.to_lowercase() != "undefined" {
return Some(prefix);
}
}
}
None
}
pub fn read_panel_config_value() -> Option<serde_json::Value> {
std::fs::read_to_string(panel_config_path())
.ok()
@@ -231,16 +295,8 @@ fn build_enhanced_path() -> String {
let home = dirs::home_dir().unwrap_or_default();
// 读取用户保存的自定义 Node.js 路径
let custom_path = openclaw_dir()
.join("clawpanel.json")
.exists()
.then(|| {
std::fs::read_to_string(openclaw_dir().join("clawpanel.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("nodePath")?.as_str().map(String::from))
})
.flatten();
let custom_path = read_panel_config_value()
.and_then(|v| v.get("nodePath")?.as_str().map(String::from));
#[cfg(target_os = "macos")]
{
@@ -254,6 +310,18 @@ fn build_enhanced_path() -> String {
"/usr/local/bin".into(),
"/opt/homebrew/bin".into(),
];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
extra.push(format!("{}/bin", prefix));
@@ -326,6 +394,18 @@ fn build_enhanced_path() -> String {
"/usr/bin".into(),
"/snap/bin".into(),
];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// NPM_CONFIG_PREFIX: 用户通过 npm config set prefix 自定义的全局安装路径
if let Ok(prefix) = std::env::var("NPM_CONFIG_PREFIX") {
extra.push(format!("{}/bin", prefix));
@@ -411,6 +491,19 @@ fn build_enhanced_path() -> String {
// 版本管理器路径优先,确保 nvm/volta/fnm 管理的 Node.js 被优先检测到
let mut extra: Vec<String> = vec![];
for configured in openclaw_search_paths() {
let dir = if configured.is_file() {
configured.parent().map(|p| p.to_path_buf())
} else {
Some(configured)
};
if let Some(dir) = dir {
if dir.is_dir() {
extra.push(dir.to_string_lossy().to_string());
}
}
}
// 1. NVM_SYMLINKnvm-windows 活跃版本符号链接,如 D:\nodejs—— 最高优先级
// 增强:尝试解析符号链接目标
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
@@ -546,6 +639,15 @@ fn build_enhanced_path() -> String {
if !appdata.is_empty() {
extra.push(format!(r"{}\npm", appdata));
}
if let Some(prefix) = windows_npm_global_prefix() {
let prefix_path = std::path::Path::new(&prefix);
if prefix_path.is_dir() {
let prefix_str = prefix_path.to_string_lossy().to_string();
if !extra.contains(&prefix_str) {
extra.push(prefix_str);
}
}
}
// 6.5 standalone 安装目录(集中管理,避免多处硬编码)
// standalone 安装后通过注册表写入用户 PATH但当前进程的 PATH 环境变量不会

View File

@@ -12,7 +12,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use crate::models::types::ServiceStatus;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tauri::Emitter;
/// OpenClaw 官方服务的友好名称映射
@@ -57,6 +57,161 @@ struct GuardianEventPayload {
message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GatewayOwnerRecord {
pid: Option<u32>,
port: u16,
cli_path: Option<String>,
openclaw_dir: String,
started_at: String,
started_by: String,
}
fn normalize_owned_path(path: impl AsRef<std::path::Path>) -> String {
let path_ref = path.as_ref();
path_ref
.canonicalize()
.unwrap_or_else(|_| path_ref.to_path_buf())
.to_string_lossy()
.to_string()
}
fn gateway_owner_path() -> std::path::PathBuf {
crate::commands::openclaw_dir().join("gateway-owner.json")
}
fn current_gateway_owner_signature() -> (u16, String, Option<String>) {
let openclaw_dir = normalize_owned_path(crate::commands::openclaw_dir());
let cli_path = crate::utils::resolve_openclaw_cli_path()
.map(|p| normalize_owned_path(std::path::PathBuf::from(p)));
(crate::commands::gateway_listen_port(), openclaw_dir, cli_path)
}
fn read_gateway_owner() -> Option<GatewayOwnerRecord> {
let content = std::fs::read_to_string(gateway_owner_path()).ok()?;
serde_json::from_str(&content).ok()
}
fn write_gateway_owner(pid: Option<u32>) -> Result<(), String> {
let owner_path = gateway_owner_path();
if let Some(parent) = owner_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 Gateway owner 目录失败: {e}"))?;
}
let (port, openclaw_dir, cli_path) = current_gateway_owner_signature();
let record = GatewayOwnerRecord {
pid,
port,
cli_path,
openclaw_dir,
started_at: chrono::Local::now().to_rfc3339(),
started_by: "clawpanel".into(),
};
let content = serde_json::to_string_pretty(&record)
.map_err(|e| format!("序列化 Gateway owner 失败: {e}"))?;
std::fs::write(owner_path, content).map_err(|e| format!("写入 Gateway owner 失败: {e}"))
}
fn clear_gateway_owner() {
let _ = std::fs::remove_file(gateway_owner_path());
}
fn is_current_gateway_owner(owner: &GatewayOwnerRecord, pid: Option<u32>) -> bool {
if owner.started_by != "clawpanel" {
return false;
}
let (port, openclaw_dir, cli_path) = current_gateway_owner_signature();
if owner.port != port {
return false;
}
if normalize_owned_path(&owner.openclaw_dir) != openclaw_dir {
return false;
}
let owner_cli_path = owner.cli_path.as_ref().map(normalize_owned_path);
match (owner_cli_path.as_deref(), cli_path.as_deref()) {
(Some(owner_cli), Some(current_cli)) if owner_cli == current_cli => {}
_ => return false,
}
if let (Some(owner_pid), Some(current_pid)) = (owner.pid, pid) {
if owner_pid != current_pid {
return false;
}
}
true
}
fn is_gateway_owned_by_current_instance(pid: Option<u32>) -> bool {
read_gateway_owner()
.as_ref()
.map(|owner| is_current_gateway_owner(owner, pid))
.unwrap_or(false)
}
fn foreign_gateway_error(pid: Option<u32>) -> String {
let pid_suffix = pid
.map(|value| format!(" (PID: {value})"))
.unwrap_or_default();
format!(
"检测到端口 {} 上已有其他 OpenClaw Gateway 正在运行{},且不属于当前面板实例。为避免误接管,请先关闭该实例,或将当前 CLI/目录绑定到它对应的安装。",
crate::commands::gateway_listen_port(),
pid_suffix
)
}
fn ensure_owned_gateway_or_err(pid: Option<u32>) -> Result<(), String> {
if is_gateway_owned_by_current_instance(pid) {
Ok(())
} else {
Err(foreign_gateway_error(pid))
}
}
async fn current_gateway_runtime(label: &str) -> (bool, Option<u32>) {
#[cfg(target_os = "windows")]
{
platform::check_service_status(0, label)
}
#[cfg(target_os = "macos")]
{
platform::check_service_status(0, label)
}
#[cfg(target_os = "linux")]
{
platform::check_service_status(0, label).await
}
}
async fn wait_for_gateway_running(label: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let (running, pid) = current_gateway_runtime(label).await;
if running {
write_gateway_owner(pid)?;
return Ok(());
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Err(format!(
"Gateway 启动超时,请查看 {}",
crate::commands::openclaw_dir()
.join("logs")
.join("gateway.err.log")
.display()
))
}
async fn wait_for_gateway_stopped(label: &str, timeout: Duration) -> Result<(), String> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let (running, _) = current_gateway_runtime(label).await;
if !running {
clear_gateway_owner();
return Ok(());
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
Err("Gateway 停止超时,请手动检查进程".into())
}
static GUARDIAN_STATE: OnceLock<Arc<Mutex<GuardianRuntimeState>>> = OnceLock::new();
static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false);
@@ -262,34 +417,30 @@ async fn guardian_tick(app: &tauri::AppHandle) {
async fn start_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::start_service_impl(label)
platform::start_service_impl(label)?;
}
#[cfg(not(target_os = "macos"))]
{
platform::start_service_impl(label).await
platform::start_service_impl(label).await?;
}
wait_for_gateway_running(label, Duration::from_secs(15)).await
}
async fn stop_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::stop_service_impl(label)
platform::stop_service_impl(label)?;
}
#[cfg(not(target_os = "macos"))]
{
platform::stop_service_impl(label).await
platform::stop_service_impl(label).await?;
}
wait_for_gateway_stopped(label, Duration::from_secs(10)).await
}
async fn restart_service_impl_internal(label: &str) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
platform::restart_service_impl(label)
}
#[cfg(not(target_os = "macos"))]
{
platform::restart_service_impl(label).await
}
stop_service_impl_internal(label).await?;
start_service_impl_internal(label).await
}
pub fn start_backend_guardian(app: tauri::AppHandle) {
@@ -427,8 +578,6 @@ mod platform {
}
}
let enhanced = crate::commands::enhanced_path();
let log_dir = crate::commands::openclaw_dir().join("logs");
fs::create_dir_all(&log_dir).ok();
@@ -444,13 +593,11 @@ mod platform {
.open(log_dir.join("gateway.err.log"))
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
let mut cmd = Command::new("openclaw");
let mut cmd = crate::utils::openclaw_command();
cmd.arg("gateway")
.env("PATH", &enhanced)
.stdin(std::process::Stdio::null())
.stdout(stdout_log)
.stderr(stderr_log);
crate::commands::apply_proxy_env(&mut cmd);
cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。".to_string()
@@ -478,7 +625,10 @@ mod platform {
}
}
Err("Gateway 启动超时,请查看 ~/.openclaw/logs/gateway.err.log".into())
Err(format!(
"Gateway 启动超时,请查看 {}",
log_dir.join("gateway.err.log").display()
))
}
pub fn start_service_impl(label: &str) -> Result<(), String> {
@@ -568,6 +718,7 @@ mod platform {
Ok(())
}
#[allow(dead_code)]
pub fn restart_service_impl(label: &str) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(label);
@@ -892,6 +1043,12 @@ mod platform {
}
fn check_cli_installed_inner() -> bool {
if let Some(path) = crate::utils::resolve_openclaw_cli_path() {
if Path::new(&path).exists() {
return true;
}
}
// 方式1: 检查常见文件路径(零进程,最快)
for path in candidate_cli_paths() {
if path.exists() {
@@ -1020,17 +1177,14 @@ mod platform {
));
}
let enhanced = crate::commands::enhanced_path();
let (stdout_log, stderr_log) = create_gateway_log_files()?;
let mut cmd = std::process::Command::new("cmd");
cmd.args(["/c", "openclaw", "gateway"])
.env("PATH", &enhanced)
let mut cmd = crate::utils::openclaw_command();
cmd.arg("gateway")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(stdout_log)
.stderr(stderr_log);
crate::commands::apply_proxy_env(&mut cmd);
// 记录 spawn 前的已知 PID
let before_pid = *LAST_KNOWN_GATEWAY_PID.lock().unwrap();
@@ -1143,6 +1297,7 @@ mod platform {
}
}
#[allow(dead_code)]
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
stop_service_impl(_label).await?;
start_service_impl(_label).await
@@ -1403,13 +1558,20 @@ mod platform {
}
}
Err("Gateway 启动超时,请查看 ~/.openclaw/logs/gateway.err.log".into())
Err(format!(
"Gateway 启动超时,请查看 {}",
crate::commands::openclaw_dir()
.join("logs")
.join("gateway.err.log")
.display()
))
}
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
gateway_command("stop").await
}
#[allow(dead_code)]
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
gateway_command("restart").await
}
@@ -1449,17 +1611,23 @@ pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
let mut results = Vec::new();
for label in labels.iter().map(String::as_str) {
// Windows 使用 platform::check_service_status含真实 PID 检测)
#[cfg(target_os = "windows")]
let (running, pid) = platform::check_service_status(_uid, label);
#[cfg(not(target_os = "windows"))]
let (running, pid) = check_tcp_service_status(_uid, label);
let (running, pid) = current_gateway_runtime(label).await;
let owned_by_current_instance = running && is_gateway_owned_by_current_instance(pid);
let ownership = if !running {
Some("stopped".to_string())
} else if owned_by_current_instance {
Some("owned".to_string())
} else {
Some("foreign".to_string())
};
results.push(ServiceStatus {
label: label.to_string(),
pid,
running,
description: desc_map.get(label).unwrap_or(&"").to_string(),
cli_installed,
ownership,
owned_by_current_instance: Some(owned_by_current_instance),
});
}
@@ -1468,18 +1636,33 @@ pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
#[tauri::command]
pub async fn start_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
write_gateway_owner(pid)?;
guardian_mark_manual_start();
return Ok(());
}
guardian_mark_manual_start();
start_service_impl_internal(&label).await
}
#[tauri::command]
pub async fn stop_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
}
guardian_mark_manual_stop();
stop_service_impl_internal(&label).await
}
#[tauri::command]
pub async fn restart_service(label: String) -> Result<(), String> {
let (running, pid) = current_gateway_runtime(&label).await;
if running {
ensure_owned_gateway_or_err(pid)?;
}
guardian_pause("manual restart");
guardian_mark_manual_start();
let result = restart_service_impl_internal(&label).await;

View File

@@ -76,7 +76,9 @@ pub fn run() {
config::init_openclaw_config,
config::check_node,
config::check_node_at_path,
config::check_openclaw_at_path,
config::scan_node_paths,
config::scan_openclaw_paths,
config::save_custom_node_path,
config::write_env_file,
config::list_backups,
@@ -139,8 +141,13 @@ pub fn run() {
extensions::install_clawapp,
// Agent 管理
agent::list_agents,
agent::get_agent_detail,
agent::list_agent_files,
agent::read_agent_file,
agent::write_agent_file,
agent::add_agent,
agent::delete_agent,
agent::update_agent_config,
agent::update_agent_identity,
agent::update_agent_model,
agent::backup_agent,

View File

@@ -8,6 +8,8 @@ pub struct ServiceStatus {
pub description: String,
/// CLI 工具是否已安装Windows/Linux: openclaw CLI
pub cli_installed: bool,
pub ownership: Option<String>,
pub owned_by_current_instance: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -1,6 +1,11 @@
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
pub fn is_rejected_cli_path(cli_path: &str) -> bool {
let lower = cli_path.replace('\\', "/").to_lowercase();
lower.contains("/.cherrystudio/") || lower.contains("cherry-studio")
}
/// 读取 clawpanel.json 中用户绑定的 CLI 路径
fn bound_cli_path() -> Option<std::path::PathBuf> {
let config = crate::commands::read_panel_config_value()?;
@@ -9,13 +14,37 @@ fn bound_cli_path() -> Option<std::path::PathBuf> {
return None;
}
let p = std::path::PathBuf::from(raw);
if p.exists() {
if p.exists() && !is_rejected_cli_path(&p.to_string_lossy()) {
Some(p)
} else {
None
}
}
fn apply_openclaw_dir_env(cmd: &mut std::process::Command) {
let openclaw_dir = crate::commands::openclaw_dir();
let config_path = openclaw_dir.join("openclaw.json");
cmd.env("OPENCLAW_HOME", &openclaw_dir);
cmd.env("OPENCLAW_STATE_DIR", &openclaw_dir);
cmd.env("OPENCLAW_CONFIG_PATH", &config_path);
}
fn apply_openclaw_dir_env_tokio(cmd: &mut tokio::process::Command) {
let openclaw_dir = crate::commands::openclaw_dir();
let config_path = openclaw_dir.join("openclaw.json");
cmd.env("OPENCLAW_HOME", &openclaw_dir);
cmd.env("OPENCLAW_STATE_DIR", &openclaw_dir);
cmd.env("OPENCLAW_CONFIG_PATH", &config_path);
}
fn configured_cli_candidates() -> Vec<std::path::PathBuf> {
crate::commands::openclaw_search_paths()
.into_iter()
.filter_map(|p| crate::commands::config::resolve_openclaw_cli_input_path(&p))
.filter(|p| !is_rejected_cli_path(&p.to_string_lossy()))
.collect()
}
/// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径
/// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致
/// "\"node\"" is not recognized 错误
@@ -25,10 +54,15 @@ fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
if let Some(bound) = bound_cli_path() {
return Some(bound);
}
for candidate in configured_cli_candidates() {
if candidate.exists() {
return Some(candidate);
}
}
let path = crate::commands::enhanced_path();
for dir in path.split(';') {
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
if candidate.exists() {
if candidate.exists() && !is_rejected_cli_path(&candidate.to_string_lossy()) {
return Some(candidate);
}
}
@@ -58,12 +92,17 @@ pub fn resolve_openclaw_cli_path() -> Option<String> {
if let Some(bound) = bound_cli_path() {
return Some(bound.to_string_lossy().to_string());
}
for candidate in configured_cli_candidates() {
if candidate.exists() {
return Some(candidate.to_string_lossy().to_string());
}
}
#[cfg(target_os = "windows")]
{
let path = crate::commands::enhanced_path();
for dir in path.split(';') {
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
if candidate.exists() {
if candidate.exists() && !is_rejected_cli_path(&candidate.to_string_lossy()) {
return Some(candidate.to_string_lossy().to_string());
}
}
@@ -125,6 +164,7 @@ pub fn openclaw_command() -> std::process::Command {
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/c").arg(cmd_path);
cmd.env("PATH", &enhanced);
apply_openclaw_dir_env(&mut cmd);
crate::commands::apply_proxy_env(&mut cmd);
cmd.creation_flags(CREATE_NO_WINDOW);
return cmd;
@@ -133,17 +173,18 @@ pub fn openclaw_command() -> std::process::Command {
let mut cmd = std::process::Command::new("cmd");
cmd.arg("/c").arg("openclaw");
cmd.env("PATH", &enhanced);
apply_openclaw_dir_env(&mut cmd);
crate::commands::apply_proxy_env(&mut cmd);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
#[cfg(not(target_os = "windows"))]
{
let bin = bound_cli_path()
.map(|p| p.to_string_lossy().to_string())
let bin = resolve_openclaw_cli_path()
.unwrap_or_else(|| "openclaw".into());
let mut cmd = std::process::Command::new(bin);
cmd.env("PATH", crate::commands::enhanced_path());
apply_openclaw_dir_env(&mut cmd);
crate::commands::apply_proxy_env(&mut cmd);
cmd
}
@@ -160,6 +201,7 @@ pub fn openclaw_command_async() -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("cmd");
cmd.arg("/c").arg(cmd_path);
cmd.env("PATH", &enhanced);
apply_openclaw_dir_env_tokio(&mut cmd);
crate::commands::apply_proxy_env_tokio(&mut cmd);
cmd.creation_flags(CREATE_NO_WINDOW);
return cmd;
@@ -168,17 +210,18 @@ pub fn openclaw_command_async() -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("cmd");
cmd.arg("/c").arg("openclaw");
cmd.env("PATH", &enhanced);
apply_openclaw_dir_env_tokio(&mut cmd);
crate::commands::apply_proxy_env_tokio(&mut cmd);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
#[cfg(not(target_os = "windows"))]
{
let bin = bound_cli_path()
.map(|p| p.to_string_lossy().to_string())
let bin = resolve_openclaw_cli_path()
.unwrap_or_else(|| "openclaw".into());
let mut cmd = tokio::process::Command::new(bin);
cmd.env("PATH", crate::commands::enhanced_path());
apply_openclaw_dir_env_tokio(&mut cmd);
crate::commands::apply_proxy_env_tokio(&mut cmd);
cmd
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.10.0",
"version": "0.11.0",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",
@@ -38,7 +38,7 @@
],
"windows": {
"webviewInstallMode": {
"type": "embedBootstrapper",
"type": "offlineInstaller",
"silent": true
},
"nsis": {

View File

@@ -4,6 +4,8 @@
* 自动注入当前页面上下文到 AI 助手会话
*/
import { t } from '../lib/i18n.js'
const BOT_ICON = '<svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect x="5" y="8" width="14" height="12" rx="2"/><path d="M9 13h0"/><path d="M15 13h0"/><path d="M10 17h4"/></svg>'
const POS_KEY = 'clawpanel-fab-pos'
@@ -41,7 +43,7 @@ export function initAIFab() {
export function openAIDrawerWithError(errorCtx) {
sessionStorage.setItem('assistant-error-context', JSON.stringify({
scene: errorCtx.scene || '',
title: errorCtx.title || '操作失败',
title: errorCtx.title || t('common.operationFailed'),
hint: errorCtx.hint || '',
error: truncate(errorCtx.error || '', 3000),
ts: Date.now(),
@@ -53,7 +55,7 @@ export function openAIDrawerWithError(errorCtx) {
_fab.el.classList.add('has-error')
} else {
import('./toast.js')
.then(({ toast }) => toast('已保存诊断上下文,可从侧边栏进入「晴辰助手」继续处理', 'info'))
.then(({ toast }) => toast(t('assistant.contextSavedToast', { assistant: t('sidebar.assistant') }), 'info'))
.catch(() => {})
}
} else {
@@ -71,7 +73,7 @@ function truncate(str, max) {
function createFab() {
const fab = document.createElement('button')
fab.className = 'ai-fab'
fab.title = 'AI 助手'
fab.title = t('sidebar.assistant')
fab.innerHTML = BOT_ICON
document.body.appendChild(fab)
@@ -228,7 +230,7 @@ function showDragHintOnce(el) {
if (!el || localStorage.getItem(HINT_KEY)) return
const tip = document.createElement('div')
tip.className = 'ai-fab-hint'
tip.textContent = '长按可拖动'
tip.textContent = t('assistant.dragHint')
el.appendChild(tip)
localStorage.setItem(HINT_KEY, '1')
setTimeout(() => tip.remove(), 4000)

View File

@@ -10,6 +10,8 @@
* 6. 不在聊天/助手页面时触发(避免打断对话)
*/
import { t } from '../lib/i18n.js'
const KEYS = {
firstOpen: 'clawpanel_first_open',
openCount: 'clawpanel_open_count',
@@ -66,23 +68,23 @@ export function tryShowEngagement() {
_showing = true
localStorage.setItem(KEYS.lastShown, String(Date.now()))
const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel一键搭建、便捷管理模型和 Agent还内置 AI 助手帮你排查问题小白也能轻松上手https://claw.qt.cool'
const shareText = t('engagement.shareText')
const overlay = document.createElement('div')
overlay.className = 'engage-overlay'
overlay.innerHTML = `
<div class="engage-modal">
<button class="engage-close" title="关闭">&times;</button>
<button class="engage-close" title="${t('common.close')}">&times;</button>
<div class="engage-header">
<div class="engage-icon">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</div>
<div class="engage-title">感谢你使用 ClawPanel</div>
<div class="engage-title">${t('engagement.title')}</div>
</div>
<div class="engage-message">
ClawPanel 是一个<strong>完全开源、免费</strong>的项目,由晴辰云团队专职维护、持续更新。如果它帮到了你,对我们最大的鼓励就是:
${t('engagement.message')}
</div>
<div class="engage-actions-grid">
@@ -91,8 +93,8 @@ export function tryShowEngagement() {
<svg viewBox="0 0 24 24" width="22" height="22" fill="#f59e0b" stroke="#f59e0b" stroke-width="1"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</div>
<div class="engage-action-text">
<div class="engage-action-title">GitHub Star</div>
<div class="engage-action-desc">点个 Star 是最直接的支持</div>
<div class="engage-action-title">${t('engagement.starTitle')}</div>
<div class="engage-action-desc">${t('engagement.starDesc')}</div>
</div>
</a>
<div class="engage-action-card engage-action-share" data-action="copy-share">
@@ -100,34 +102,34 @@ export function tryShowEngagement() {
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</div>
<div class="engage-action-text">
<div class="engage-action-title">分享给朋友</div>
<div class="engage-action-desc">复制推荐文案,让更多人知道</div>
<div class="engage-action-title">${t('engagement.shareTitle')}</div>
<div class="engage-action-desc">${t('engagement.shareDesc')}</div>
</div>
</div>
</div>
<div class="engage-section-label">扫码加入社区交流群,第一时间获取更新和帮助</div>
<div class="engage-section-label">${t('engagement.communityLabel')}</div>
<div class="engage-qrcodes">
<a class="engage-qr-item" href="https://qt.cool/c/OpenClaw" target="_blank" rel="noopener">
<img src="/images/OpenClaw-QQ.png" alt="QQ 交流群" />
<div class="engage-qr-label">QQ 群</div>
<img src="/images/OpenClaw-QQ.png" alt="${t('engagement.qqAlt')}" />
<div class="engage-qr-label">${t('engagement.qqLabel')}</div>
</a>
<a class="engage-qr-item" href="https://qt.cool/c/OpenClawWx" target="_blank" rel="noopener">
<img src="/images/OpenClawWx.png" alt="微信交流群" />
<div class="engage-qr-label">微信群</div>
<img src="/images/OpenClawWx.png" alt="${t('engagement.wechatAlt')}" />
<div class="engage-qr-label">${t('engagement.wechatLabel')}</div>
</a>
<a class="engage-qr-item" href="https://qt.cool/c/OpenClawDY" target="_blank" rel="noopener">
<img src="/images/OpenClaw-DY.png" alt="抖音交流群" />
<div class="engage-qr-label">抖音群</div>
<img src="/images/OpenClaw-DY.png" alt="${t('engagement.douyinAlt')}" />
<div class="engage-qr-label">${t('engagement.douyinLabel')}</div>
</a>
<a class="engage-qr-item" href="https://qt.cool/c/feishu" target="_blank" rel="noopener">
<img src="https://qt.cool/c/feishu/qr.png" alt="飞书交流群" />
<div class="engage-qr-label">飞书群</div>
<img src="https://qt.cool/c/feishu/qr.png" alt="${t('engagement.feishuAlt')}" />
<div class="engage-qr-label">${t('engagement.feishuLabel')}</div>
</a>
</div>
<div class="engage-footer">
<span class="engage-today-dismiss">今日不再弹窗</span>
<span class="engage-today-dismiss">${t('engagement.dismissToday')}</span>
</div>
</div>
`
@@ -146,7 +148,7 @@ export function tryShowEngagement() {
overlay.querySelector('[data-action="copy-share"]').onclick = () => {
navigator.clipboard.writeText(shareText).then(() => {
const desc = overlay.querySelector('[data-action="copy-share"] .engage-action-desc')
if (desc) { desc.textContent = '✅ 已复制,去分享吧!'; setTimeout(() => { desc.textContent = '复制推荐文案,让更多人知道' }, 2000) }
if (desc) { desc.textContent = t('engagement.shareCopied'); setTimeout(() => { desc.textContent = t('engagement.shareDesc') }, 2000) }
})
}
}

View File

@@ -2,6 +2,8 @@
* Modal 弹窗组件
*/
import { t } from '../lib/i18n.js'
// 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构
function escapeAttr(str) {
if (!str) return ''
@@ -24,11 +26,11 @@ export function showConfirm(message) {
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:400px">
<div class="modal-title">确认操作</div>
<div class="modal-title">${t('common.confirmAction')}</div>
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(message)}</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
<button class="btn btn-danger btn-sm" data-action="confirm">确定</button>
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
<button class="btn btn-danger btn-sm" data-action="confirm">${t('common.confirm')}</button>
</div>
</div>
`
@@ -91,8 +93,8 @@ export function showModal({ title, fields, onConfirm }) {
<div class="modal-title">${title}</div>
${fieldHtml}
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
<button class="btn btn-primary btn-sm" data-action="confirm">确定</button>
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
</div>
</div>
`
@@ -156,7 +158,7 @@ export function showContentModal({ title, content, buttons = [], width = 480 })
<div class="modal-title">${title}</div>
<div class="modal-content-body">${content}</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
${btnsHtml}
</div>
</div>
@@ -190,14 +192,14 @@ export function showUpgradeModal(title) {
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:520px">
<div class="modal-title">${title || '升级 OpenClaw'}</div>
<div class="modal-title">${title || t('common.upgradeOpenClaw')}</div>
<div class="upgrade-progress-wrap">
<div class="upgrade-progress-bar"><div class="upgrade-progress-fill" style="width:0%"></div></div>
<div class="upgrade-progress-text">准备中...</div>
<div class="upgrade-progress-text">${t('common.preparing')}</div>
</div>
<div class="upgrade-log-box"></div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
<button class="btn btn-secondary btn-sm" data-action="close">${t('common.close')}</button>
</div>
</div>
`
@@ -237,7 +239,7 @@ export function showUpgradeModal(title) {
_taskBar.className = 'upgrade-task-bar'
_taskBar.innerHTML = `
<span class="upgrade-task-bar-text">${text.textContent}</span>
<button class="btn btn-sm upgrade-task-bar-open">查看详情</button>
<button class="btn btn-sm upgrade-task-bar-open">${t('common.viewDetails')}</button>
<button class="btn btn-sm btn-ghost upgrade-task-bar-dismiss">×</button>
`
_taskBar.querySelector('.upgrade-task-bar-open').onclick = reopenModal
@@ -276,16 +278,16 @@ export function showUpgradeModal(title) {
setProgress(pct) {
fill.style.width = pct + '%'
let statusText
if (pct >= 100) statusText = '完成'
else if (pct >= 75) statusText = '正在安装...'
else if (pct >= 30) statusText = '正在下载依赖...'
else statusText = '准备中...'
if (pct >= 100) statusText = t('common.completed')
else if (pct >= 75) statusText = t('common.installingProgress')
else if (pct >= 30) statusText = t('common.downloadingDependencies')
else statusText = t('common.preparing')
text.textContent = statusText
updateTaskBar(statusText)
},
setDone(msg) {
_finished = true
text.textContent = msg || '升级完成'
text.textContent = msg || t('common.upgradeCompleted')
fill.style.width = '100%'
fill.classList.add('done')
if (_taskBar) { _taskBar.remove(); _taskBar = null }
@@ -293,11 +295,11 @@ export function showUpgradeModal(title) {
},
setError(msg) {
_finished = true
text.textContent = msg || '升级失败'
text.textContent = msg || t('common.upgradeFailed')
fill.classList.add('error')
if (_taskBar) {
const span = _taskBar.querySelector('.upgrade-task-bar-text')
if (span) { span.textContent = msg || '升级失败'; span.style.color = 'var(--error)' }
if (span) { span.textContent = msg || t('common.upgradeFailed'); span.style.color = 'var(--error)' }
}
closeBtn.focus()
},

View File

@@ -139,8 +139,8 @@ export function renderSidebar(el) {
<img src="/images/logo.png" alt="ClawPanel">
</div>
<span class="sidebar-title">ClawPanel</span>
<button class="sidebar-collapse-btn" id="btn-sidebar-collapse" title="折叠/展开">${collapsed ? '»' : '«'}</button>
<button class="sidebar-close-btn" id="btn-sidebar-close" title="关闭菜单">&times;</button>
<button class="sidebar-collapse-btn" id="btn-sidebar-collapse" title="${t('sidebar.collapse')}">${collapsed ? '»' : '«'}</button>
<button class="sidebar-close-btn" id="btn-sidebar-close" title="${t('sidebar.closeMenu')}">&times;</button>
</div>
${showSwitcher ? `<div class="instance-switcher" id="instance-switcher">
<button class="instance-current" id="btn-instance-toggle">

View File

@@ -129,7 +129,8 @@ export async function detectOpenclawStatus() {
// 顺便检测 Gateway 运行状态
if (services.status === 'fulfilled' && services.value?.length > 0) {
_setGatewayRunning(services.value[0]?.running === true)
const gw = services.value.find?.(s => s.label === 'ai.openclaw.gateway') || services.value[0]
_setGatewayRunning(gw?.running === true && gw?.owned_by_current_instance !== false)
}
} catch {
_openclawReady = false
@@ -176,13 +177,17 @@ async function _tryAutoRestart() {
// 重启前再次确认端口确实空闲,防止端口被其他程序占用时无限拉起
try {
const services = await api.getServicesStatus()
const gw = services?.[0]
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0]
if (gw?.running) {
console.log('[guardian] 端口仍在使用中,跳过自动重启')
console.log(gw?.owned_by_current_instance === false
? '[guardian] 检测到外部 Gateway 正在占用端口,跳过自动重启'
: '[guardian] 端口仍在使用中,跳过自动重启')
_gwStopCount = 0
_gatewayRunning = true
_gatewayRunningSince = Date.now()
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
if (gw?.owned_by_current_instance !== false) {
_gatewayRunning = true
_gatewayRunningSince = Date.now()
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
}
return
}
} catch {}
@@ -204,7 +209,10 @@ export async function refreshGatewayStatus() {
try {
const services = await api.getServicesStatus()
if (services?.length > 0) {
const nowRunning = services[0]?.running === true
const gw = services.find?.(s => s.label === 'ai.openclaw.gateway') || services[0]
const ownedRunning = gw?.running === true && gw?.owned_by_current_instance !== false
const foreignRunning = gw?.running === true && gw?.owned_by_current_instance === false
const nowRunning = ownedRunning
if (nowRunning) {
_gwStopCount = 0
if (!_gatewayRunning) {
@@ -217,8 +225,12 @@ export async function refreshGatewayStatus() {
_autoRestartCount = 0
}
} else {
_gwStopCount++
if (_gwStopCount >= 2 || !_gatewayRunning) {
if (foreignRunning) {
_gwStopCount = 0
} else {
_gwStopCount++
}
if (foreignRunning || _gwStopCount >= 2 || !_gatewayRunning) {
_setGatewayRunning(false)
}
}

View File

@@ -0,0 +1,197 @@
import { api } from './tauri-api.js'
import { showContentModal } from '../components/modal.js'
import { t } from './i18n.js'
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function cliSourceLabel(source) {
if (source === 'standalone') return t('dashboard.cliSourceStandalone')
if (source === 'npm-zh') return t('dashboard.cliSourceNpmZh')
if (source === 'npm-official') return t('dashboard.cliSourceNpmOfficial')
if (source === 'npm-global') return t('dashboard.cliSourceNpmGlobal')
return t('dashboard.cliSourceUnknown')
}
function openclawInstallationIdentity(installation) {
const rawPath = String(installation?.path || '').trim()
if (!rawPath) return ''
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
if (!isWin) return rawPath
return rawPath
.replace(/\//g, '\\')
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
.toLowerCase()
}
function dedupeOpenclawInstallations(list = []) {
const map = new Map()
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
for (const installation of Array.isArray(list) ? list : []) {
const key = openclawInstallationIdentity(installation)
if (!key) continue
const existing = map.get(key)
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
map.set(key, installation)
}
}
return [...map.values()]
}
function readBoundCliPath(panelConfig) {
return String(panelConfig?.openclawCliPath || '').trim()
}
let _foreignGatewayPromptKey = ''
export function isForeignGatewayService(service) {
return service?.ownership === 'foreign' || (service?.running === true && service?.owned_by_current_instance === false)
}
export function isForeignGatewayError(error) {
const text = String(error?.message || error || '')
return text.includes('不属于当前面板实例')
|| text.includes('误接管')
|| text.includes('其他 OpenClaw Gateway')
}
export async function maybeShowForeignGatewayBindingPrompt({ service = null, onRefresh = null } = {}) {
if (!isForeignGatewayService(service)) {
_foreignGatewayPromptKey = ''
return false
}
const panelConfig = await api.readPanelConfig().catch(() => null)
if (readBoundCliPath(panelConfig)) {
return false
}
const promptKey = `${service?.label || 'ai.openclaw.gateway'}::${service?.pid || 'unknown'}::${service?.ownership || 'foreign'}`
if (_foreignGatewayPromptKey === promptKey) {
return false
}
_foreignGatewayPromptKey = promptKey
await showGatewayConflictGuidance({ service, onRefresh })
return true
}
export async function showGatewayConflictGuidance({ error = null, service = null, onRefresh = null, reason = null } = {}) {
const [versionInfo, dirInfo, panelConfig] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.getOpenclawDir().catch(() => null),
api.readPanelConfig().catch(() => null),
])
const currentCli = versionInfo?.cli_path || t('common.unknown')
const currentCliSource = cliSourceLabel(versionInfo?.cli_source)
const currentDir = dirInfo?.path || t('common.unknown')
const boundCliPath = readBoundCliPath(panelConfig)
const displayBoundCliPath = boundCliPath || t('services.guidanceCliBindingAuto')
const installations = dedupeOpenclawInstallations(Array.isArray(versionInfo?.all_installations) ? versionInfo.all_installations : [])
const message = error ? escapeHtml(String(error.message || error)) : ''
const pid = service?.pid || null
const hasForeignGateway = reason === 'foreign-gateway'
|| (!!error && reason !== 'multiple-installations')
|| (reason !== 'multiple-installations' && isForeignGatewayService(service))
const hasUnboundForeignGateway = hasForeignGateway && !boundCliPath
const hasMultiInstall = reason === 'multiple-installations' || installations.length > 1
const settingsLabel = t('sidebar.settings')
const title = hasUnboundForeignGateway
? t('services.guidanceTitleForeignUnbound')
: hasForeignGateway
? t('services.guidanceTitleForeign')
: hasMultiInstall
? t('services.guidanceTitleMultiInstall')
: t('services.guidanceTitleCheck')
const summaryText = hasUnboundForeignGateway
? t('services.guidanceSummaryForeignUnbound')
: hasForeignGateway
? t('services.guidanceSummaryForeign')
: hasMultiInstall
? t('services.guidanceSummaryMultiInstall')
: t('services.guidanceSummaryCheck')
const suggestionOne = hasUnboundForeignGateway
? t('services.guidanceSuggestionBindAutoDetected', { settings: settingsLabel })
: hasForeignGateway
? t('services.guidanceSuggestionBindForeign', { settings: settingsLabel })
: t('services.guidanceSuggestionBind', { settings: settingsLabel })
const suggestionTwo = hasForeignGateway
? t('services.guidanceSuggestionStopForeign')
: t('services.guidanceSuggestionRefresh')
const suggestionThree = t('services.guidanceSuggestionInstallations')
const settingsButtonLabel = hasUnboundForeignGateway ? t('services.guidanceBindCliBtn') : t('sidebar.settings')
const installationHtml = installations.length
? installations.map(inst => {
const badges = [
inst.active ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(t('settings.cliActive'))}</span>` : '',
inst.version ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(t('settings.cliVersion'))}: ${escapeHtml(inst.version)}</span>` : '',
inst.source ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(cliSourceLabel(inst.source))}</span>` : '',
].filter(Boolean).join(' ')
return `
<div style="padding:10px 12px;border:1px solid var(--border-light);border-radius:10px;background:var(--bg-secondary);margin-top:8px">
<div style="font-size:12px;word-break:break-all;font-family:var(--font-mono)">${escapeHtml(inst.path)}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">${badges}</div>
</div>`
}).join('')
: `<div style="padding:10px 12px;border:1px dashed var(--border-light);border-radius:10px;background:var(--bg-secondary);margin-top:8px;color:var(--text-secondary)">${escapeHtml(t('services.guidanceNoInstallations', { settings: settingsLabel }))}</div>`
const content = `
<div style="display:flex;flex-direction:column;gap:12px;font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.7">
<div style="padding:12px;border-radius:10px;background:rgba(245,158,11,0.12);color:var(--warning)">
${escapeHtml(summaryText)}
</div>
${message ? `<div style="padding:10px 12px;border-radius:10px;background:var(--bg-secondary);font-family:var(--font-mono);word-break:break-all">${message}</div>` : ''}
<div style="display:grid;grid-template-columns:1fr;gap:8px">
<div><strong>${escapeHtml(t('services.guidanceCurrentBindingTitle'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(displayBoundCliPath)}</div></div>
<div><strong>${escapeHtml(t('settings.openclawCli'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(currentCli)}</div><div style="margin-top:4px;color:var(--text-tertiary)">${escapeHtml(currentCliSource)}</div></div>
<div><strong>${escapeHtml(t('settings.openclawDir'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(currentDir)}</div></div>
${pid ? `<div><strong>PID</strong><div style="margin-top:4px">${escapeHtml(pid)}</div></div>` : ''}
</div>
<div>
<strong>${escapeHtml(t('services.guidanceHandlingTitle'))}</strong>
<div style="margin-top:6px">
${escapeHtml(suggestionOne)}
</div>
<div style="margin-top:6px">
${escapeHtml(suggestionTwo)}
</div>
<div style="margin-top:6px">
${escapeHtml(suggestionThree)}
</div>
</div>
<div>
<strong>${escapeHtml(t('services.guidanceInstallationsTitle'))}</strong>
${installationHtml}
</div>
</div>
`
const overlay = showContentModal({
title,
content,
width: 760,
buttons: [
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-primary btn-sm' },
{ id: 'gateway-conflict-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
],
})
overlay.querySelector('#gateway-conflict-open-settings')?.addEventListener('click', () => {
overlay.close()
window.location.hash = '#/settings'
})
overlay.querySelector('#gateway-conflict-refresh')?.addEventListener('click', async () => {
overlay.close()
if (typeof onRefresh === 'function') {
await onRefresh()
}
})
return overlay
}

View File

@@ -3,12 +3,19 @@
* Tauri 环境用 invokeWeb 模式走 dev-api 后端
*/
import { t } from './i18n.js'
const isTauri = !!window.__TAURI_INTERNALS__
// 仅在 Node.js 后端实现的命令Tauri Rust 不处理),强制走 webInvoke
const WEB_ONLY_CMDS = new Set([
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
'instance_health_check', 'instance_health_all',
'docker_info', 'docker_list_containers', 'docker_create_container',
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
'docker_remove_container', 'docker_pull_image', 'docker_pull_status',
'docker_list_images', 'docker_list_nodes', 'docker_add_node',
'docker_remove_node', 'docker_cluster_overview',
'get_deploy_mode',
])
@@ -76,6 +83,10 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
// 清除指定命令的缓存(写操作后调用)
function invalidate(...cmds) {
if (!cmds.length) {
_cache.clear()
return
}
for (const [k] of _cache) {
if (cmds.some(c => k.startsWith(c))) _cache.delete(k)
}
@@ -110,12 +121,12 @@ async function webInvoke(cmd, args) {
if (resp.status === 401) {
// Tauri 模式下不触发登录浮层Tauri 有自己的认证流程)
if (!isTauri && window.__clawpanel_show_login) window.__clawpanel_show_login()
throw new Error('需要登录')
throw new Error(t('common.loginRequired'))
}
// 检测后端是否可用:如果返回的是 HTML非 JSON说明后端未运行
const ct = (resp.headers.get('content-type') || '').toLowerCase()
if (ct.includes('text/html') || ct.includes('text/plain')) {
throw new Error('后端服务未运行,该功能需要 Web 部署模式')
throw new Error(t('common.backendWebModeRequired'))
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }))
@@ -195,10 +206,15 @@ export const api = {
// Agent 管理
listAgents: () => cachedInvoke('list_agents'),
getAgentDetail: (id) => cachedInvoke('get_agent_detail', { id }, 5000),
listAgentFiles: (id) => cachedInvoke('list_agent_files', { id }, 5000),
readAgentFile: (id, name) => invoke('read_agent_file', { id, name }),
writeAgentFile: (id, name, content) => { invalidate('list_agent_files', 'read_agent_file'); return invoke('write_agent_file', { id, name, content }) },
updateAgentConfig: (id, config) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_config', { id, config }) },
addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) },
deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) },
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) },
updateAgentModel: (id, model) => { invalidate('list_agents'); return invoke('update_agent_model', { id, model }) },
deleteAgent: (id) => { invalidate('list_agents', 'get_agent_detail'); return invoke('delete_agent', { id }) },
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_identity', { id, name, emoji }) },
updateAgentModel: (id, model) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_model', { id, model }) },
backupAgent: (id) => invoke('backup_agent', { id }),
// 日志(短缓存)
@@ -234,14 +250,14 @@ export const api = {
getAgentBindings: (agentId) => invoke('get_agent_bindings', { agentId }),
listAllBindings: () => invoke('list_all_bindings'),
saveAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('save_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || {} }) },
deleteAgentBinding: (agentId, channel, accountId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null }) },
deleteAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || null }) },
deleteAgentAllBindings: (agentId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_all_bindings', { agentId }) },
// 面板配置 (clawpanel.json)
getOpenclawDir: () => invoke('get_openclaw_dir'),
relaunchApp: () => invoke('relaunch_app'),
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => invoke('write_panel_config', { config }),
writePanelConfig: (config) => { invalidate(); return invoke('write_panel_config', { config }).then(r => { invoke('invalidate_path_cache').catch(() => {}); return r }) },
testProxy: (url) => invoke('test_proxy', { url: url || null }),
// 安装/部署
@@ -249,7 +265,9 @@ export const api = {
initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') },
checkNode: () => cachedInvoke('check_node', {}, 60000),
checkNodeAtPath: (nodeDir) => invoke('check_node_at_path', { nodeDir }),
checkOpenclawAtPath: (cliPath) => invoke('check_openclaw_at_path', { cliPath }),
scanNodePaths: () => invoke('scan_node_paths'),
scanOpenclawPaths: () => invoke('scan_openclaw_paths'),
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node', 'get_services_status'); invoke('invalidate_path_cache').catch(() => {}); return r }),
invalidatePathCache: () => invoke('invalidate_path_cache'),
checkGit: () => cachedInvoke('check_git', {}, 60000),
@@ -307,6 +325,22 @@ export const api = {
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
instanceHealthAll: () => invoke('instance_health_all'),
// Docker 管理(当前由 Web/dev-api 提供)
dockerInfo: (nodeId) => invoke('docker_info', { nodeId: nodeId || null }),
dockerListContainers: (nodeId, all = true) => invoke('docker_list_containers', { nodeId: nodeId || null, all }),
dockerCreateContainer: (payload) => invoke('docker_create_container', payload || {}),
dockerStartContainer: (nodeId, containerId) => invoke('docker_start_container', { nodeId: nodeId || null, containerId }),
dockerStopContainer: (nodeId, containerId) => invoke('docker_stop_container', { nodeId: nodeId || null, containerId }),
dockerRestartContainer: (nodeId, containerId) => invoke('docker_restart_container', { nodeId: nodeId || null, containerId }),
dockerRemoveContainer: (nodeId, containerId, force = false) => invoke('docker_remove_container', { nodeId: nodeId || null, containerId, force }),
dockerPullImage: (payload) => invoke('docker_pull_image', payload || {}),
dockerPullStatus: (requestId) => invoke('docker_pull_status', { requestId }),
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId: nodeId || null }),
dockerListNodes: () => invoke('docker_list_nodes', {}),
dockerAddNode: (name, endpoint) => invoke('docker_add_node', { name, endpoint }),
dockerRemoveNode: (nodeId) => invoke('docker_remove_node', { nodeId }),
dockerClusterOverview: () => invoke('docker_cluster_overview', {}),
// 前端热更新
checkFrontendUpdate: () => invoke('check_frontend_update'),

View File

@@ -471,6 +471,7 @@
"noDesc": "No description",
"default": "Default",
"backup": "Backup",
"detail": "Details",
"edit": "Edit",
"delete": "Delete",
"labelName": "Name:",
@@ -504,7 +505,102 @@
"deleteFailed": "Delete failed",
"backingUp": "Backing up Agent \"{id}\"...",
"backupDone": "Backup done: {file}",
"backupFailed": "Backup failed"
"backupFailed": "Backup failed",
"detailHint": "Click a card or the Details button to open the new Agent detail page."
},
"agentDetail": {
"back": "← Back to Agents",
"tabOverview": "Overview",
"tabFiles": "Files",
"tabChannels": "Channels",
"tabTools": "Tools",
"tabSkills": "Skills",
"defaultAgent": "Default Agent",
"basicInfo": "Basic Info",
"agentId": "Agent ID",
"name": "Name",
"emoji": "Emoji",
"workspace": "Workspace",
"notSet": "Not set",
"modelConfig": "Model Config",
"primaryModel": "Primary Model",
"fallbackModels": "Fallback Models",
"addFallback": "+ Add Fallback",
"noFallback": "No fallback models configured.",
"removeFallback": "Remove",
"thinkingLevel": "Thinking Level",
"thinkingOff": "Off",
"thinkingMinimal": "Minimal",
"thinkingLow": "Low",
"thinkingMedium": "Medium",
"thinkingHigh": "High",
"thinkingXhigh": "Very High",
"thinkingAdaptive": "Adaptive",
"saveOverview": "Save Config",
"saving": "Saving...",
"saveSuccess": "Config saved",
"saveFailed": "Save failed",
"filesTitle": "Bootstrap Files",
"filesDesc": "Core files in the Agent directory that define behavior, identity and memory.",
"fileAgents": "Agent Rules",
"fileAgentsDesc": "Session rules, constraints and operating instructions.",
"fileSoul": "Soul / Persona",
"fileSoulDesc": "Persona, background and behavioral guidelines.",
"fileTools": "Tool Allowlist",
"fileToolsDesc": "Allowed tools for this Agent.",
"fileIdentity": "Identity",
"fileIdentityDesc": "Name, emoji and avatar-related identity settings.",
"fileUser": "User Context",
"fileUserDesc": "User background information supplied to the Agent.",
"fileHeartbeat": "Heartbeat",
"fileHeartbeatDesc": "Periodic inspection or background task instructions.",
"fileBootstrap": "Bootstrap",
"fileBootstrapDesc": "Initialization guidance for first startup.",
"fileMemory": "Memory",
"fileMemoryDesc": "Persistent Agent memory data.",
"fileExists": "Created",
"fileMissing": "Missing",
"fileEdit": "Edit",
"fileView": "View",
"fileCreate": "Create",
"fileSize": "Size",
"fileUpdated": "Updated",
"editFileTitle": "Edit {name}",
"fileSaved": "File saved",
"fileSaveFailed": "Failed to save file",
"fileCreated": "File created",
"channelsTitle": "Channel Bindings",
"channelsDesc": "Manage message channels bound to this Agent.",
"addBinding": "+ Add Binding",
"noBindings": "This Agent has no bindings yet.",
"removeBinding": "Unbind",
"bindingChannel": "Channel",
"bindingAccount": "Account",
"bindingType": "Type",
"bindingRemoved": "Binding removed",
"bindingAdded": "Binding added",
"bindingFailed": "Binding action failed",
"selectChannel": "Select Channel",
"accountOptional": "Account ID (optional)",
"loadFailed": "Load failed",
"toolsTitle": "Tool Permissions",
"toolsDesc": "Configure the base tool profile and extra allow / deny rules for this Agent.",
"toolProfile": "Tool Profile",
"toolAllow": "Explicit Allow",
"toolAllowHint": "Comma or newline separated. Acts as the base allowlist when set.",
"toolAlsoAllow": "Also Allow",
"toolAlsoAllowHint": "Extra tools allowed on top of the selected profile.",
"toolDeny": "Explicit Deny",
"toolDenyHint": "Higher priority than allow/profile.",
"saveTools": "Save Tool Config",
"toolsSaved": "Tool config saved",
"skillsTitle": "Skill Allowlist",
"skillsDesc": "Choose which Skills this Agent may use. Empty means no skill allowlist is enforced.",
"saveSkills": "Save Skill Config",
"skillsSaved": "Skill config saved",
"noSkills": "No Skills available",
"skillDisabled": "Disabled",
"skillUnavailable": "Unavailable"
},
"gateway": {
"title": "Gateway Config",

View File

@@ -11,6 +11,7 @@ import services from './modules/services.js'
import settings from './modules/settings.js'
import models from './modules/models.js'
import agents from './modules/agents.js'
import agentDetail from './modules/agentDetail.js'
import gateway from './modules/gateway.js'
import security from './modules/security.js'
import communication from './modules/communication.js'
@@ -28,12 +29,13 @@ import logs from './modules/logs.js'
import assistant from './modules/assistant.js'
import toast from './modules/toast.js'
import modal from './modules/modal.js'
import engagement from './modules/engagement.js'
const MODULES = {
common, sidebar, instance, dashboard, services, settings,
models, agents, gateway, security, communication, channels,
models, agents, agentDetail, gateway, security, communication, channels,
memory, cron, usage, skills, chat, chatDebug, setup, about,
ext, logs, assistant, toast, modal,
ext, logs, assistant, toast, modal, engagement,
}
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */

View File

@@ -59,6 +59,13 @@ export default {
operationDone: _('操作完成', 'Operation complete', '', '操作完了', '작업 완료'),
taskStarted: _('后台任务已启动,请等待完成...', 'Background task started, please wait...', '後台任務已啟動,請等待完成...'),
webModeNoLog: _('Web 模式:安装过程日志不可用,请等待完成...', 'Web mode: Install logs unavailable, please wait...', 'Web 模式:安裝過程日誌不可用,請等待完成...'),
versionAvailable: _('ClawPanel v{version} 可用', 'ClawPanel v{version} available', 'ClawPanel v{version} 可用', 'ClawPanel v{version} が利用可能です', 'ClawPanel v{version} 사용 가능', 'ClawPanel v{version} đã có sẵn'),
updateMethod: _('更新方法', 'Update Method', '更新方法', '更新方法', '업데이트 방법', 'Cách cập nhật'),
releaseNotes: _('更新日志', 'Release Notes', '更新日誌', 'リリースノート', '릴리스 노트', 'Ghi chú phát hành'),
dismissVersion: _('忽略此版本', 'Ignore this version', '忽略此版本', 'このバージョンを無視', '이 버전 무시', 'Bỏ qua phiên bản này'),
updateToVersion: _('更新到 v{version}', 'Update to v{version}', '更新到 v{version}', 'v{version} に更新', 'v{version}(으)로 업데이트', 'Cập nhật lên v{version}'),
runOnServer: _('在服务器上执行以下命令:', 'Run the following commands on the server:', '在伺服器上執行以下命令:', 'サーバーで次のコマンドを実行してください:', '서버에서 다음 명령을 실행하세요:', 'Hãy chạy các lệnh sau trên máy chủ:'),
updateCommandHint: _('如果 git pull 失败,可先执行 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 丢弃本地修改。<br>路径请替换为实际的 ClawPanel 安装目录。', 'If <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git pull</code> fails, run <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> first to discard local changes.<br>Replace the path with your actual ClawPanel install directory.', '如果 git pull 失敗,可先執行 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 捨棄本地修改。<br>路徑請替換為實際的 ClawPanel 安裝目錄。', 'git pull に失敗した場合は、先に <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> を実行してローカル変更を破棄してください。<br>パスは実際の ClawPanel インストールディレクトリに置き換えてください。', 'git pull 이 실패하면 먼저 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 를 실행해 로컬 변경을 버리세요.<br>경로는 실제 ClawPanel 설치 디렉터리로 바꿔 주세요.', 'Nếu git pull thất bại, hãy chạy <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> trước để bỏ các thay đổi cục bộ.<br>Hãy thay đường dẫn bằng thư mục cài đặt ClawPanel thực tế của bạn.'),
updateReady: _('已就绪', 'ready', '已就緒', '準備完了'),
reloadApp: _('重载应用', 'Reload App', '重載應用', 'アプリを再読み込み', '앱 새로고침'),
rollback: _('回退', 'Rollback', '', 'ロールバック'),
@@ -66,9 +73,10 @@ export default {
rollbackFailed: _('回退失败: ', 'Rollback failed: ', '回退失敗: '),
newVersion: _('新版本', 'New version', '', '新バージョン', '새 버전'),
hotUpdate: _('热更新', 'Hot Update', '熱更新', 'ホットアップデート', '핫 업데이트'),
fullInstaller: _('完整安装包', 'Full Installer', '完整安裝包', 'フルインストーラー'),
fullInstaller: _('完整安装包', 'Full Installer', '完整安裝包', 'フルインストーラー', '풀 인스톨러'),
downloading: _('下载中...', 'Downloading...', '下載中...', 'ダウンロード中...', '다운로드 중...'),
downloadDone: _('更新下载完成,点击「重载应用」生效', 'Update downloaded, click "Reload App" to apply', '更新下載完成,点擊「重載應用」生效', '更新ダウンロード完了、「アプリを再読み込み」をクリックして適用'),
downloadFailedShort: _('下载失败', 'Download failed', '下載失敗', 'ダウンロード失敗', '다운로드 실패', 'Tải xuống thất bại'),
downloadFailed: _('下载失败: ', 'Download failed: ', '下載失敗: ', 'ダウンロード失敗: ', '다운로드 실패: '),
retry: _('重试', 'Retry', '重試', '再試行', '재시도'),
needFullUpdate: _('需要更新完整安装包', 'Full install package update required', '需要更新完整安裝包'),

View File

@@ -0,0 +1,80 @@
import { _ } from '../helper.js'
export default {
back: _('← 返回 Agent 列表', '← Back to Agents'),
tabOverview: _('概览', 'Overview'),
tabFiles: _('文件', 'Files'),
tabChannels: _('渠道', 'Channels'),
tabTools: _('工具', 'Tools'),
tabSkills: _('技能', 'Skills'),
defaultAgent: _('默认 Agent', 'Default Agent'),
basicInfo: _('基本信息', 'Basic Info'),
agentId: _('Agent ID', 'Agent ID'),
name: _('名称', 'Name'),
emoji: _('表情', 'Emoji'),
workspace: _('工作区', 'Workspace'),
notSet: _('未设置', 'Not set'),
modelConfig: _('模型配置', 'Model Config'),
primaryModel: _('主模型', 'Primary Model'),
fallbackModels: _('备选模型', 'Fallback Models'),
addFallback: _('+ 添加备选', '+ Add Fallback'),
noFallback: _('未配置备选模型(主模型不可用时无法切换)', 'No fallback models configured.'),
removeFallback: _('移除', 'Remove'),
thinkingLevel: _('推理级别', 'Thinking Level'),
thinkingOff: _('关闭', 'Off'),
thinkingMinimal: _('最小', 'Minimal'),
thinkingLow: _('低', 'Low'),
thinkingMedium: _('中', 'Medium'),
thinkingHigh: _('高', 'High'),
thinkingXhigh: _('极高', 'Very High'),
thinkingAdaptive: _('自适应', 'Adaptive'),
saveOverview: _('保存配置', 'Save Config'),
saving: _('保存中...', 'Saving...'),
saveSuccess: _('配置已保存', 'Config saved'),
saveFailed: _('保存失败', 'Save failed'),
filesTitle: _('Bootstrap 文件', 'Bootstrap Files'),
filesDesc: _('Agent 工作区中的核心配置文件,定义 Agent 的行为、身份和记忆', 'Core files in the Agent directory that define behavior, identity and memory.'),
fileExists: _('已创建', 'Created'),
fileMissing: _('未创建', 'Missing'),
fileEdit: _('编辑', 'Edit'),
fileView: _('查看', 'View'),
fileCreate: _('创建', 'Create'),
noFiles: _('无文件', 'No files'),
fileSize: _('大小', 'Size'),
fileUpdated: _('更新时间', 'Updated'),
editFileTitle: _('编辑 {name}', 'Edit {name}'),
fileSaved: _('文件已保存', 'File saved'),
fileSaveFailed: _('文件保存失败', 'Failed to save file'),
fileCreated: _('文件已创建', 'File created'),
channelsTitle: _('渠道绑定', 'Channel Bindings'),
channelsDesc: _('管理此 Agent 绑定的消息渠道', 'Manage message channels bound to this Agent.'),
addBinding: _('+ 添加绑定', '+ Add Binding'),
noBindings: _('此 Agent 尚未绑定任何渠道', 'This Agent has no bindings yet.'),
removeBinding: _('解绑', 'Unbind'),
bindingRemoved: _('已解除绑定', 'Binding removed'),
bindingAdded: _('已添加绑定', 'Binding added'),
bindingFailed: _('绑定操作失败', 'Binding action failed'),
selectChannel: _('选择渠道', 'Select Channel'),
accountOptional: _('账号 ID可选', 'Account ID (optional)'),
accountOptionalPlaceholder: _('留空表示所有账号', 'Leave empty to match all accounts'),
removeBindingConfirm: _('确定解除渠道绑定 {channel} ?', 'Unbind channel {channel}?'),
loadFailed: _('加载失败', 'Load failed'),
toolsTitle: _('工具权限', 'Tool Permissions'),
toolsDesc: _('配置 Agent 可用工具的基础 profile 与额外 allow / deny 规则。', 'Configure the base tool profile and extra allow / deny rules for this Agent.'),
toolProfile: _('工具配置模板', 'Tool Profile'),
toolAllow: _('显式允许', 'Explicit Allow'),
toolAllowHint: _('逗号或换行分隔。设置后会作为基础 allowlist。', 'Comma or newline separated. Acts as the base allowlist when set.'),
toolAlsoAllow: _('追加允许', 'Also Allow'),
toolAlsoAllowHint: _('在 profile 基础上额外开放的工具。', 'Extra tools allowed on top of the selected profile.'),
toolDeny: _('显式禁止', 'Explicit Deny'),
toolDenyHint: _('优先级高于 allow/profile。', 'Higher priority than allow/profile.'),
saveTools: _('保存工具配置', 'Save Tool Config'),
toolsSaved: _('工具配置已保存', 'Tool config saved'),
skillsTitle: _('技能白名单', 'Skill Allowlist'),
skillsDesc: _('勾选当前 Agent 允许使用的 Skills留空表示不启用技能白名单。', 'Choose which Skills this Agent may use. Empty means no skill allowlist is enforced.'),
saveSkills: _('保存技能配置', 'Save Skill Config'),
skillsSaved: _('技能配置已保存', 'Skill config saved'),
noSkills: _('未获取到可用 Skills', 'No Skills available'),
skillDisabled: _('已禁用', 'Disabled'),
skillUnavailable: _('环境未满足', 'Unavailable'),
}

View File

@@ -10,6 +10,7 @@ export default {
noDesc: _('无描述', 'No description', '無描述', '説明なし', '설명 없음'),
default: _('默认', 'Default', '預設', 'デフォルト', '기본', 'Mặc định', 'Predeterminado', 'Padrão', 'По умолчанию', 'Par défaut', 'Standard'),
backup: _('备份', 'Backup', '備份', 'バックアップ', '백업', 'Sao lưu', 'Respaldo', '', 'Резервная копия', 'Sauvegarde'),
detail: _('详情', 'Details', '詳情', '詳細', '상세', 'Chi tiết', 'Detalles', 'Detalhes', 'Подробности', 'Détails', 'Details'),
edit: _('编辑', 'Edit', '編輯', '編集', '편집', 'Sửa', 'Editar', 'Editar', 'Редактировать', 'Modifier', 'Bearbeiten'),
delete: _('删除', 'Delete', '刪除', '削除', '삭제', 'Xóa', 'Eliminar', 'Excluir', 'Удалить', 'Supprimer', 'Löschen'),
labelName: _('名称:', 'Name:', '名稱:', '名前:', '이름:'),
@@ -44,4 +45,5 @@ export default {
backingUp: _('正在备份 Agent「{id}」...', 'Backing up Agent "{id}"...', '正在備份 Agent「{id}」...', 'Agent「{id}」をバックアップ中...', 'Agent「{id}」 백업 중...'),
backupDone: _('备份完成: {file}', 'Backup done: {file}', '備份完成: {file}', 'バックアップ完了: {file}', '백업 완료: {file}'),
backupFailed: _('备份失败', 'Backup failed', '備份失敗', 'バックアップ失敗', '백업 실패'),
detailHint: _('点击卡片空白区域或“详情”按钮,进入新的 Agent 详情页。', 'Click a card or the Details button to open the new Agent detail page.', '點擊卡片空白區域或「詳情」按鈕,進入新的 Agent 詳情頁。', 'カード余白または「詳細」ボタンから Agent 詳細へ移動します。', '카드 빈 영역 또는 상세 버튼으로 Agent 상세 페이지로 이동합니다.'),
}

View File

@@ -1,14 +1,14 @@
import { _ } from '../helper.js'
export default {
modeChat: _('聊天', 'Chat', '', 'チャット', '채팅'),
modeChatDesc: _('纯对话,不使用工具', 'Pure conversation, no tools', '純對話,不使用工具', '会話のみ、ツール不使用', '대화만, 도구 미사용'),
modePlan: _('规划', 'Plan', '規劃', 'プランニング', '플래닝'),
modePlanDesc: _('AI 只读分析,工具仅查看', 'AI read-only analysis, tools view-only', 'AI 唯讀分析,工具僅查看', 'AI 読み取り専用分析、ツールは閲覧のみ', 'AI 읽기 전용 분석, 도구는 조회만'),
modeExecute: _('执行', 'Execute', '執行', '実行', '실행'),
modeExecuteDesc: _('允许执行命令和修改文件', 'Allow running commands and modifying files', '允許執行命令和修改檔案', 'コマンド実行とファイル変更を許可', '명령 실행 및 파일 변경 허용'),
modeUnlimited: _('无限', 'Unlimited', '無限', '無制限', '무제한'),
modeUnlimitedDesc: _('跳过危险确认,全自动执行', 'Skip danger confirmations, fully automated', '略過危險確認,全自動執行', '危険確認をスキップ、全自動実行', '위험 확인 건너뛰기, 완전 자동 실행'),
modeChat: _('聊天', 'Chat', '', 'チャット', '채팅', 'Trò chuyện'),
modeChatDesc: _('纯对话,不使用工具', 'Pure conversation, no tools', '純對話,不使用工具', '会話のみ、ツール不使用', '대화만, 도구 미사용', 'Chỉ trò chuyện, không dùng công cụ'),
modePlan: _('规划', 'Plan', '規劃', 'プランニング', '플래닝', 'Lập kế hoạch'),
modePlanDesc: _('AI 只读分析,工具仅查看', 'AI read-only analysis, tools view-only', 'AI 唯讀分析,工具僅查看', 'AI 読み取り専用分析、ツールは閲覧のみ', 'AI 읽기 전용 분석, 도구는 조회만', 'AI chỉ phân tích, công cụ chỉ để xem'),
modeExecute: _('执行', 'Execute', '執行', '実行', '실행', 'Thực thi'),
modeExecuteDesc: _('允许执行命令和修改文件', 'Allow running commands and modifying files', '允許執行命令和修改檔案', 'コマンド実行とファイル変更を許可', '명령 실행 및 파일 변경 허용', 'Cho phép chạy lệnh và sửa tệp'),
modeUnlimited: _('无限', 'Unlimited', '無限', '無制限', '무제한', 'Không giới hạn'),
modeUnlimitedDesc: _('跳过危险确认,全自动执行', 'Skip danger confirmations, fully automated', '略過危險確認,全自動執行', '危険確認をスキップ、全自動実行', '위험 확인 건너뛰기, 완전 자동 실행', 'Bỏ qua xác nhận nguy hiểm, tự động hoàn toàn'),
apiHintOpenai: _('兼容 OpenAI 接口大多数中转站、Ollama 等)', 'OpenAI compatible API (most proxies, Ollama, etc.)', '相容 OpenAI 介面大多數中轉站、Ollama 等)'),
apiHintAnthropic: _('Anthropic Claude 原生接口', 'Anthropic Claude native API', 'Anthropic Claude 原生介面'),
apiHintGemini: _('Google Gemini 原生接口', 'Google Gemini native API', 'Google Gemini 原生介面'),
@@ -16,6 +16,8 @@ export default {
apiKeyPlaceholderOpenai: _('sk-... 或中转站密钥', 'sk-... or proxy key', 'sk-... 或中轉站金鑰'),
defaultName: _('晴辰助手', 'Assistant', '', 'AI アシスタント', 'AI 어시스턴트', 'Trợ lý AI', 'Asistente IA', 'Assistente IA', 'ИИ-ассистент', 'Assistant IA', 'KI-Assistent'),
defaultPersonality: _('专业、友好、乐于助人', 'Professional, friendly, and helpful', '專業、友好、樂於助人', 'プロフェッショナル、フレンドリー、親切'),
contextSavedToast: _('已保存诊断上下文,可从侧边栏进入「{assistant}」继续处理', 'Diagnostic context saved. Open "{assistant}" from the sidebar to continue.', '已儲存診斷上下文,可從側邊欄進入「{assistant}」繼續處理', '診断コンテキストを保存しました。サイドバーの「{assistant}」から続けて処理できます。', '진단 컨텍스트를 저장했습니다. 사이드바의 "{assistant}"에서 이어서 처리하세요.', 'Đã lưu ngữ cảnh chẩn đoán. Hãy mở "{assistant}" từ thanh bên để tiếp tục.'),
dragHint: _('长按可拖动', 'Hold to drag', '長按可拖動', '長押しでドラッグできます', '길게 눌러 드래그', 'Nhấn giữ để kéo'),
skillCheckConfig: _('检查配置', 'Check Config', '檢查設定'),
skillCheckConfigDesc: _('检查 OpenClaw 配置文件是否正确', 'Check if OpenClaw config files are correct', '檢查 OpenClaw 設定檔案是否正確'),
skillDiagnoseGateway: _('诊断 Gateway', 'Diagnose Gateway', '诊斷 Gateway'),
@@ -58,6 +60,9 @@ export default {
errConfigFirst: _('请先在设置中配置 API', 'Please configure the API in Settings first', '請先在設定中設定 API', '先に設定で API を設定してください', '먼저 설정에서 API를 설정하세요'),
errTimeout: _('请求超时', 'Request timed out', '請求逾時', 'リクエストタイムアウト', '요청 타임아웃'),
errStreamTimeout: _('流式响应超时({seconds}s 无数据),连接可能已断开', 'Streaming response timed out ({seconds}s no data), connection may be lost', '流式回應逾時({seconds}s 無資料),連線可能已斷開'),
errHomeUnavailable: _('无法获取主目录', 'Unable to determine home directory', '無法取得主目錄', 'ホームディレクトリを取得できません', '홈 디렉터리를 확인할 수 없습니다', 'Không thể xác định thư mục home'),
errWorkspaceMissing: _('Agent 工作区不存在: {agentId}', 'Agent workspace does not exist: {agentId}', 'Agent 工作區不存在: {agentId}', 'Agent ワークスペースが存在しません: {agentId}', 'Agent 작업공간이 없습니다: {agentId}', 'Workspace của Agent không tồn tại: {agentId}'),
errInvalidResponse: _('AI 未返回有效响应', 'AI did not return a valid response', 'AI 未返回有效回應', 'AI から有効な応答が返されませんでした', 'AI가 유효한 응답을 반환하지 않았습니다', 'AI không trả về phản hồi hợp lệ'),
toolRunCmd: _('执行命令', 'Run command', '執行命令'),
toolReadFile: _('读取文件', 'Read file', '讀取檔案'),
toolWriteFile: _('写入文件', 'Write file', '写入檔案'),
@@ -117,7 +122,7 @@ export default {
autoRoundsHint: _('设为 0 表示每次都询问', 'Set to 0 to ask every time', '設為 0 表示每次都询問'),
autoRoundsUnit: _('轮', 'rounds'),
autoRoundsDefault: _('默认', 'Default', '預設'),
autoRoundsUnlimited: _('无限', 'Unlimited', '無限'),
autoRoundsUnlimited: _('无限', 'Unlimited', '無限', '無限', '무제한', 'Không giới hạn'),
errorAnalyze: _('让 AI 分析', 'Let AI analyze', '讓 AI 分析'),
errorDismiss: _('忽略', 'Dismiss'),
errorShowLog: _('展开日志', 'Show log', '展開日誌'),
@@ -131,10 +136,10 @@ export default {
settingsTitle: _('助手设置', 'Assistant Settings', '助手設定', 'アシスタント設定', '어시스턴트 설정', 'Cài đặt trợ lý', 'Configuración del asistente', 'Configurações do assistente', 'Настройки ассистента', 'Paramètres de l\'assistant', 'Assistenten-Einstellungen'),
settings: _('设置', 'Settings', '設定', '設定', '설정', 'Cài đặt', 'Configuración', 'Configurações', 'Настройки', 'Paramètres', 'Einstellungen'),
settingsSaved: _('设置已保存', 'Settings saved', '設定已儲存', '設定を保存しました', '설정 저장됨', 'Đã lưu cài đặt', 'Configuración guardada', 'Configurações salvas', 'Настройки сохранены', 'Paramètres enregistrés', 'Einstellungen gespeichert'),
settingsTabApi: _('API 配置', 'API Config', 'API 設定', 'API 設定', 'API 설정'),
settingsTabTools: _('工具', 'Tools', '', 'ツール', '도구'),
settingsTabPersona: _('人设', 'Persona', '人設', 'ペルソナ', '페르소나'),
settingsTabKnowledge: _('知识库', 'Knowledge Base', '知識庫', 'ナレッジベース', '지식 베이스'),
settingsTabApi: _('API 配置', 'API Config', 'API 設定', 'API 設定', 'API 설정', 'Cấu hình API'),
settingsTabTools: _('工具', 'Tools', '', 'ツール', '도구', 'Công cụ'),
settingsTabPersona: _('人设', 'Persona', '人設', 'ペルソナ', '페르소나', 'Nhân dạng'),
settingsTabKnowledge: _('知识库', 'Knowledge Base', '知識庫', 'ナレッジベース', '지식 베이스', 'Kho tri thức'),
apiType: _('接口类型', 'API Type', '介面類型', 'API タイプ', 'API 유형'),
model: _('模型', 'Model', '', 'モデル', '모델', 'Mô hình', 'Modelo', 'Modelo', 'Модель', 'Modèle', 'Modell'),
temperature: _('温度', 'Temperature', '溫度', '温度', '온도', 'Nhiệt độ', 'Temperatura', 'Temperatura', 'Температура', 'Température', 'Temperatur'),

View File

@@ -250,12 +250,15 @@ export default {
pluginNotInstalled: _('未安装', 'Not installed', '未安裝', 'プラグイン未インストール'),
latestVersion: _('最新版', 'Latest version'),
clickInstallBelow: _('点击下方「一键安装插件」开始', 'Click "Install Plugin" below to get started', '点擊下方「一鍵安裝外掛」開始'),
pluginCompatErrorHint: _('请点击「一键安装插件」重新安装兼容版本', 'Click "Install Plugin" to reinstall a compatible version', '請點擊「一鍵安裝外掛」重新安裝相容版本'),
executing: _('正在执行', 'Executing', '正在執行'),
executingShort: _('执行中...', 'Executing...', '執行中...'),
executingAction: _('正在执行渠道动作', 'Executing channel action', '正在執行頻道動作'),
executionDone: _('执行完成', 'Execution complete', '執行完成'),
executionFailed: _('执行失败', 'Execution failed', '執行失敗'),
unknownError: _('未知错误', 'Unknown error', '未知錯誤'),
downloadingPlugin: _('正在下载,请稍候(首次安装可能需要几分钟)...', 'Downloading, please wait (the first install may take a few minutes)...', '正在下載,請稍候(首次安裝可能需要幾分鐘)...'),
weixinOpenInBrowser: _('或点击此链接在浏览器中打开', 'Or open this link in your browser', '或點擊此連結在瀏覽器中打開'),
execute: _('执行', 'Execute', '執行'),
channelConnected: _('🎉 渠道连接成功!正在刷新列表…', '🎉 Channel connected! Refreshing list...', '🎉 頻道連線成功!正在重新整理列表…'),
reDetecting: _('重新检测…', 'Re-detecting...', '重新檢測…'),

View File

@@ -41,4 +41,31 @@ export default {
survivalRate: _('存活率', 'Uptime', '', '稼働率', '가동률', 'Tỷ lệ hoạt động', 'Disponibilidad', 'Disponibilidade', 'Доступность', 'Disponibilité', 'Verfügbarkeit'),
settings: _('设置', 'Settings', '設定', '設定', '설정', 'Cài đặt', 'Configuración', 'Configurações', 'Настройки', 'Paramètres', 'Einstellungen'),
update: _('更新', 'Update', '', '更新', '업데이트', 'Cập nhật', 'Actualizar', 'Atualizar', 'Обновить', 'Mettre à jour', 'Aktualisieren'),
loginRequired: _('需要登录', 'Login required', '需要登入', 'ログインが必要です', '로그인이 필요합니다', 'Cần đăng nhập'),
backendWebModeRequired: _('后端服务未运行,该功能需要 Web 部署模式', 'Backend service is not running. This feature requires Web deployment mode.', '後端服務未運行,此功能需要 Web 部署模式', 'バックエンドサービスが動作していません。この機能には Web デプロイモードが必要です。', '백엔드 서비스가 실행 중이 아닙니다. 이 기능은 Web 배포 모드가 필요합니다.', 'Dịch vụ backend chưa chạy. Tính năng này yêu cầu chế độ triển khai Web.'),
backendDownTitle: _('后端未启动', 'Backend offline', '後端未啟動', 'バックエンド未起動', '백엔드 미실행', 'Backend chưa khởi động'),
backendDownDesc: _('ClawPanel 后端服务未运行,无法获取真实数据。', 'ClawPanel backend is not running, so live data is unavailable.', 'ClawPanel 後端服務未執行,無法取得真實資料。', 'ClawPanel バックエンドが実行されていないため、実データを取得できません。', 'ClawPanel 백엔드가 실행 중이 아니어서 실제 데이터를 가져올 수 없습니다.', 'Dịch vụ backend của ClawPanel chưa chạy nên không thể lấy dữ liệu thực.'),
backendDownHint: _('请在服务器上启动后端服务后刷新页面。', 'Start the backend service on the server and refresh this page.', '請在伺服器上啟動後端服務後重新整理頁面。', 'サーバーでバックエンドサービスを起動してからこのページを更新してください。', '서버에서 백엔드 서비스를 시작한 뒤 이 페이지를 새로고침하세요.', 'Hãy khởi động dịch vụ backend trên máy chủ rồi tải lại trang này.'),
checkAgain: _('重新检测', 'Check again', '重新檢測', '再チェック', '다시 확인', 'Kiểm tra lại'),
checking: _('检测中...', 'Checking...', '檢測中...', '確認中...', '확인 중...', 'Đang kiểm tra...'),
backendConnectedLoading: _('后端已连接,正在加载...', 'Backend connected, loading...', '後端已連線,正在載入...', 'バックエンド接続済み、読み込み中...', '백엔드 연결됨, 로딩 중...', 'Đã kết nối backend, đang tải...'),
backendStillDown: _('后端仍未响应,请确认服务已启动', 'Backend is still unreachable. Confirm the service is running.', '後端仍未回應,請確認服務已啟動', 'バックエンドがまだ応答しません。サービスが起動しているか確認してください。', '백엔드가 아직 응답하지 않습니다. 서비스가 실행 중인지 확인하세요.', 'Backend vẫn chưa phản hồi, hãy xác nhận dịch vụ đã khởi động.'),
devMode: _('开发模式', 'Development', '開發模式', '開発モード', '개발 모드', 'Chế độ phát triển'),
prodMode: _('生产模式', 'Production', '生產模式', '本番モード', '프로덕션 모드', 'Chế độ sản xuất'),
networkError: _('网络错误', 'Network error', '網路錯誤', 'ネットワークエラー', '네트워크 오류', 'Lỗi mạng'),
defaultPasswordBanner: _('当前使用的是系统生成的默认密码,为了安全请尽快修改', 'You are using a system-generated default password. Please change it as soon as possible.', '目前使用的是系統產生的預設密碼,為了安全請盡快修改。', '現在はシステム生成のデフォルトパスワードを使用しています。安全のため早めに変更してください。', '현재 시스템이 생성한 기본 비밀번호를 사용 중입니다. 보안을 위해 가능한 빨리 변경하세요.', 'Bạn đang dùng mật khẩu mặc định do hệ thống tạo. Hãy đổi sớm để đảm bảo an toàn.'),
goSecurity: _('前往安全设置', 'Open Security', '前往安全設定', 'セキュリティ設定へ', '보안 설정으로 이동', 'Mở Bảo mật'),
pageLoadFailed: _('页面加载失败', 'Page failed to load', '頁面載入失敗', 'ページの読み込みに失敗しました', '페이지 로드 실패', 'Tải trang thất bại'),
reloadRetry: _('刷新重试', 'Reload', '重新整理重試', '再読み込み', '새로고침', 'Tải lại'),
pageLoadFailedHint: _('如果问题持续出现,请尝试重新安装 ClawPanel或在 GitHub Issues 反馈。', 'If the problem persists, try reinstalling ClawPanel or report it on GitHub Issues.', '如果問題持續出現,請嘗試重新安裝 ClawPanel或到 GitHub Issues 回報。', '問題が続く場合は ClawPanel を再インストールするか、GitHub Issues で報告してください。', '문제가 계속되면 ClawPanel을 다시 설치하거나 GitHub Issues에 제보하세요.', 'Nếu lỗi vẫn lặp lại, hãy thử cài lại ClawPanel hoặc báo trên GitHub Issues.'),
confirmAction: _('确认操作', 'Confirm action', '確認操作', '操作を確認', '작업 확인', 'Xác nhận thao tác'),
viewDetails: _('查看详情', 'View details', '查看詳情', '詳細を見る', '자세히 보기', 'Xem chi tiết'),
preparing: _('准备中...', 'Preparing...', '準備中...', '準備中...', '준비 중...', 'Đang chuẩn bị...'),
downloadingDependencies: _('正在下载依赖...', 'Downloading dependencies...', '正在下載依賴...', '依存関係をダウンロード中...', '의존성 다운로드 중...', 'Đang tải phụ thuộc...'),
installingProgress: _('正在安装...', 'Installing...', '正在安裝...', 'インストール中...', '설치 중...', 'Đang cài đặt...'),
completed: _('完成', 'Completed', '完成', '完了', '완료', 'Hoàn tất'),
upgradeOpenClaw: _('升级 OpenClaw', 'Upgrade OpenClaw', '升級 OpenClaw', 'OpenClaw をアップグレード', 'OpenClaw 업그레이드', 'Nâng cấp OpenClaw'),
upgradeCompleted: _('升级完成', 'Upgrade completed', '升級完成', 'アップグレード完了', '업그레이드 완료', 'Nâng cấp hoàn tất'),
upgradeFailed: _('升级失败', 'Upgrade failed', '升級失敗', 'アップグレード失敗', '업그레이드 실패', 'Nâng cấp thất bại'),
unknownCommand: _('未知命令', 'Unknown command', '未知命令', '不明なコマンド', '알 수 없는 명령', 'Lệnh không xác định'),
}

View File

@@ -37,6 +37,13 @@ export default {
cliSourceUnknown: _('未知来源', 'Unknown', '未知來源', '不明', '알 수 없음'),
multiInstall: _('检测到多个安装', 'Multiple installations detected', '檢測到多個安裝', '複数のインストールを検出', '여러 설치가 감지됨'),
multiInstallHint: _('在「面板设置」中可选择使用哪个', 'Choose which one to use in Settings', '在「面板設定」中可選擇使用哪個', '設定で使用するものを選択できます', '설정에서 사용할 설치를 선택할 수 있습니다'),
multiInstallCardHint: _('检测到多个安装,建议确认当前绑定的 CLI 与 OpenClaw 目录。', 'Multiple installations detected. Confirm the bound CLI and OpenClaw directory first.', '檢測到多個安裝,建議先確認目前綁定的 CLI 與 OpenClaw 目錄。', '複数のインストールが検出されました。まず CLI バインドと OpenClaw ディレクトリを確認してください。', '여러 설치가 감지되었습니다. 먼저 바인딩된 CLI와 OpenClaw 디렉터리를 확인하세요.'),
foreignGatewayHint: _('检测到外部 Gateway建议先查看引导或进入设置修正绑定。', 'External Gateway detected. Review the guidance or open Settings to correct the binding.', '檢測到外部 Gateway建議先查看引導或進入設定修正綁定。', '外部 Gateway を検出しました。ガイドを確認するか設定を開いて関連付けを修正してください。', '외부 Gateway가 감지되었습니다. 안내를 확인하거나 설정에서 바인딩을 수정하세요.'),
externalInstance: _('外部实例', 'External instance', '外部實例', '外部インスタンス', '외부 인스턴스', 'Phiên bản bên ngoài'),
externalGatewayDetected: _('检测到外部 Gateway{pid}', 'External Gateway detected{pid}', '檢測到外部 Gateway{pid}', '外部 Gateway を検出{pid}', '외부 Gateway 감지됨{pid}', 'Đã phát hiện Gateway bên ngoài{pid}'),
viewGuidance: _('查看引导', 'View guidance', '查看引導', 'ガイドを見る', '안내 보기'),
goSettings: _('去配置', 'Open Settings', '前往設定', '設定を開く', '설정 열기'),
viewOnlyStatus: _('当前面板仅查看状态', 'This panel only views status', '目前面板僅查看狀態', 'このパネルは状態表示のみです', '현재 패널은 상태만 확인합니다', 'Bảng điều khiển này chỉ xem trạng thái'),
installCount: _('{count} 个安装', '{count} installations', '{count} 個安裝', '{count} インストール', '{count}개 설치'),
retry: _('重试', 'Retry', '重試', '再試行', '재시도', 'Thử lại', 'Reintentar', 'Tentar novamente', 'Повторить', 'Réessayer', 'Wiederholen'),
servicesLoadFail: _('服务状态加载失败', 'Failed to load service status', '服務狀態載入失敗', 'サービス状態の読み込みに失敗', '서비스 상태 로드 실패'),
@@ -55,6 +62,9 @@ export default {
backupCount: _('{count} 个备份文件', '{count} backup files', '{count} 個備份檔案', '{count} バックアップファイル', '{count}개 백업 파일'),
workspaceCount: _('{count} 个独立工作区', '{count} independent workspaces', '{count} 個獨立工作區', '{count} ワークスペース', '{count}개 워크스페이스'),
runtimeVersion: _('运行时版本', 'Runtime Version', '執行時版本', 'ランタイムバージョン', '런타임 버전'),
runtimeMetaLive: _('OpenClaw 运行时', 'OpenClaw runtime', 'OpenClaw 執行時', 'OpenClaw ランタイム', 'OpenClaw 런타임'),
runtimeMetaFileRead: _('openclaw.json / 本地安装', 'openclaw.json / local install', 'openclaw.json / 本地安裝', 'openclaw.json / ローカルインストール', 'openclaw.json / 로컬 설치'),
runtimeMetaConfig: _('openclaw.json', 'openclaw.json', 'openclaw.json', 'openclaw.json', 'openclaw.json'),
remaining: _('剩余', 'Remaining', '剩餘', '残り', '남은'),
activeSessions: _('活跃会话', 'Active Sessions', '活躍對話', 'アクティブセッション', '활성 세션'),
defaultModel: _('默认模型', 'Default model', '預設模型', 'デフォルトモデル', '기본 모델'),
@@ -71,6 +81,7 @@ export default {
gwRestartSent: _('Gateway 重启指令已发送', 'Gateway restart command sent', 'Gateway 重啟指令已發送', 'Gateway 再起動コマンド送信済み', 'Gateway 재시작 명령 전송됨'),
restartFail: _('重启失败', 'Restart failed', '重啟失敗', '再起動失敗', '재시작 실패'),
gwRestarted: _('Gateway 已重启 (PID: {pid})', 'Gateway restarted (PID: {pid})', 'Gateway 已重啟 (PID: {pid})', 'Gateway 再起動済み (PID: {pid})', 'Gateway 재시작됨 (PID: {pid})'),
startTimeout: _('启动超时Gateway 可能仍在启动中', 'Start timed out, Gateway may still be starting', '啟動逾時Gateway 可能仍在啟動中', '起動タイムアウト、Gateway はまだ起動中の可能性があります', '시작 타임아웃, Gateway가 아직 시작 중일 수 있습니다', 'Khởi động quá thời gian, Gateway có thể vẫn đang khởi chạy'),
restartTimeout: _('重启超时Gateway 可能仍在启动中', 'Restart timed out, Gateway may still be starting', '重啟逾時Gateway 可能仍在啟動中', '再起動タイムアウト、Gateway はまだ起動中の可能性があります', '재시작 타임아웃, Gateway가 아직 시작 중일 수 있습니다'),
checking: _('检查中...', 'Checking...', '檢查中...', '確認中...', '확인 중...', 'Đang kiểm tra...', 'Verificando...', 'Verificando...', 'Проверка...', 'Vérification...', 'Wird geprüft...'),
versionAheadWarn: _('当前本地版本 {current} 高于推荐稳定版 {recommended},可能存在兼容风险', 'Local version {current} is ahead of recommended stable {recommended}, may have compatibility risks', '目前本地版本 {current} 高於推薦穩定版 {recommended},可能存在相容風險', 'ローカルバージョン {current} は推奨安定版 {recommended} より新しく、互換性リスクがある可能性があります', '로컬 버전 {current}이 권장 안정 버전 {recommended}보다 높아 호환성 위험이 있을 수 있습니다'),

View File

@@ -0,0 +1,22 @@
import { _ } from '../helper.js'
export default {
shareText: _('推荐一个开源的 OpenClaw 管理面板 — ClawPanel一键搭建、便捷管理模型和 Agent还内置 AI 助手帮你排查问题小白也能轻松上手https://claw.qt.cool', 'Recommend an open-source OpenClaw management panel — ClawPanel. It helps you set up quickly, manage models and Agents easily, and even includes an AI assistant for troubleshooting: https://claw.qt.cool', '推薦一個開源的 OpenClaw 管理面板 — ClawPanel一鍵搭建、便捷管理模型和 Agent還內建 AI 助手幫你排查問題小白也能輕鬆上手https://claw.qt.cool', 'OpenClaw を管理しやすいオープンソースの管理パネル「ClawPanel」をおすすめします。すばやくセットアップでき、モデルや Agent の管理も簡単で、トラブル対応用の AI アシスタントも内蔵しています: https://claw.qt.cool', '오픈소스 OpenClaw 관리 패널 ClawPanel을 추천합니다. 빠른 구축, 모델과 Agent의 편리한 관리, 그리고 문제 해결용 AI 도우미까지 제공합니다: https://claw.qt.cool', 'Giới thiệu một bảng điều khiển mã nguồn mở cho OpenClaw — ClawPanel. Thiết lập nhanh, quản lý model và Agent thuận tiện, còn có cả trợ lý AI hỗ trợ xử lý sự cố: https://claw.qt.cool', 'Te recomiendo un panel de administración open source para OpenClaw: ClawPanel. Permite desplegar rápido, gestionar modelos y Agents fácilmente e incluye un asistente de IA para resolver problemas: https://claw.qt.cool', 'Recomendo um painel de gerenciamento open source para OpenClaw — ClawPanel. Ele facilita a configuração, o gerenciamento de modelos e Agents, e ainda inclui um assistente de IA para troubleshooting: https://claw.qt.cool', 'Рекомендую open-source панель управления для OpenClaw — ClawPanel. Она помогает быстро развернуть систему, удобно управлять моделями и агентами, а также включает ИИ-помощника для диагностики: https://claw.qt.cool', 'Je recommande un panneau de gestion open source pour OpenClaw : ClawPanel. Il permet une mise en place rapide, une gestion simple des modèles et des Agents, et intègre un assistant IA pour le dépannage : https://claw.qt.cool', 'Ich empfehle ClawPanel, ein Open-Source-Verwaltungs-Panel für OpenClaw. Damit lässt sich schnell starten, Modelle und Agents bequem verwalten, und ein KI-Assistent hilft bei Problemen: https://claw.qt.cool'),
title: _('感谢你使用 ClawPanel', 'Thanks for using ClawPanel', '感謝你使用 ClawPanel', 'ClawPanel を使っていただきありがとうございます', 'ClawPanel을 사용해 주셔서 감사합니다', 'Cảm ơn bạn đã sử dụng ClawPanel', 'Gracias por usar ClawPanel', 'Obrigado por usar o ClawPanel', 'Спасибо, что используете ClawPanel', 'Merci dutiliser ClawPanel', 'Danke, dass du ClawPanel nutzt'),
message: _('ClawPanel 是一个<strong>完全开源、免费</strong>的项目,由晴辰云团队专职维护、持续更新。如果它帮到了你,对我们最大的鼓励就是:', 'ClawPanel is a <strong>fully open-source and free</strong> project, maintained full-time and continuously improved by the QingchenCloud team. If it helped you, the best encouragement for us is:', 'ClawPanel 是一個<strong>完全開源、免費</strong>的專案,由晴辰雲團隊專職維護、持續更新。如果它幫到了你,對我們最大的鼓勵就是:', 'ClawPanel は<strong>完全オープンソースで無料</strong>のプロジェクトで、QingchenCloud チームが継続的に保守・更新しています。もし役に立ったなら、私たちにとって一番の励みは次のような応援です:', 'ClawPanel은 <strong>완전한 오픈소스이자 무료</strong> 프로젝트이며, QingchenCloud 팀이 전담 유지보수와 지속적인 업데이트를 진행하고 있습니다. 도움이 되었다면 가장 큰 응원은 다음과 같습니다:', 'ClawPanel là một dự án <strong>mã nguồn mở hoàn toàn và miễn phí</strong>, được đội ngũ QingchenCloud duy trì chuyên trách và cập nhật liên tục. Nếu nó giúp ích cho bạn, sự ủng hộ lớn nhất dành cho chúng tôi là:', 'ClawPanel es un proyecto <strong>totalmente open source y gratuito</strong>, mantenido y actualizado continuamente por el equipo de QingchenCloud. Si te ha ayudado, la mejor forma de apoyarnos es:', 'ClawPanel é um projeto <strong>totalmente open source e gratuito</strong>, mantido em tempo integral e atualizado continuamente pela equipe QingchenCloud. Se ele te ajudou, o maior incentivo para nós é:', 'ClawPanel — это <strong>полностью open-source и бесплатный</strong> проект, который команда QingchenCloud поддерживает и постоянно развивает. Если он вам помог, лучшая поддержка для нас — это:', 'ClawPanel est un projet <strong>entièrement open source et gratuit</strong>, maintenu à plein temps et continuellement amélioré par léquipe QingchenCloud. Sil vous a aidé, le meilleur encouragement pour nous est :', 'ClawPanel ist ein <strong>vollständig quelloffenes und kostenloses</strong> Projekt, das vom QingchenCloud-Team kontinuierlich gepflegt und weiterentwickelt wird. Wenn es dir geholfen hat, ist die größte Unterstützung für uns:'),
starTitle: _('GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star', 'GitHub Star'),
starDesc: _('点个 Star 是最直接的支持', 'Giving us a Star is the most direct support', '點個 Star 是最直接的支持', 'Star を付けてもらえるのが一番直接的な応援です', 'Star를 눌러 주는 것이 가장 직접적인 응원입니다', 'Tặng một Star là cách ủng hộ trực tiếp nhất', 'Dar una Star es la forma más directa de apoyarnos', 'Dar uma Star é a forma mais direta de apoiar', 'Поставить Star — самый прямой способ поддержки', 'Mettre une Star est le soutien le plus direct', 'Ein Star ist die direkteste Unterstützung'),
shareTitle: _('分享给朋友', 'Share with friends', '分享給朋友', '友だちに共有', '친구에게 공유', 'Chia sẻ với bạn bè', 'Compartir con amigos', 'Compartilhar com amigos', 'Поделиться с друзьями', 'Partager avec des amis', 'Mit Freunden teilen'),
shareDesc: _('复制推荐文案,让更多人知道', 'Copy the recommendation text to spread the word', '複製推薦文案,讓更多人知道', '紹介文をコピーして、もっと多くの人に広めましょう', '추천 문구를 복사해 더 많은 사람에게 알려 주세요', 'Sao chép lời giới thiệu để nhiều người biết hơn', 'Copia el texto de recomendación para que más gente lo conozca', 'Copie o texto de recomendação para divulgar para mais pessoas', 'Скопируйте текст рекомендации, чтобы рассказать другим', 'Copiez le texte de recommandation pour le partager autour de vous', 'Kopiere den Empfehlungstext, damit mehr Leute davon erfahren'),
shareCopied: _('✅ 已复制,去分享吧!', '✅ Copied, go share it!', '✅ 已複製,去分享吧!', '✅ コピーしました。ぜひ共有してください!', '✅ 복사되었습니다. 이제 공유해 보세요!', '✅ Đã sao chép, hãy chia sẻ ngay!', '✅ ¡Copiado! ¡Ahora compártelo!', '✅ Copiado! Agora compartilhe!', '✅ Скопировано, поделитесь этим!', '✅ Copié, allez le partager !', '✅ Kopiert, jetzt teile es!'),
communityLabel: _('扫码加入社区交流群,第一时间获取更新和帮助', 'Scan to join the community groups for updates and help', '掃碼加入社群交流群,第一時間獲取更新和幫助', 'QR をスキャンしてコミュニティに参加し、最新情報やサポートを受け取りましょう', 'QR 코드를 스캔해 커뮤니티 그룹에 참여하고 업데이트와 도움을 받아보세요', 'Quét mã để tham gia cộng đồng, nhận cập nhật và hỗ trợ sớm nhất', 'Escanea para unirte a la comunidad y recibir novedades y ayuda', 'Escaneie para entrar na comunidade e receber atualizações e ajuda', 'Сканируйте код, чтобы присоединиться к сообществу и получать новости и помощь', 'Scannez pour rejoindre la communauté et recevoir des mises à jour et de laide', 'Scanne den Code, um der Community beizutreten und Updates sowie Hilfe zu erhalten'),
qqAlt: _('QQ 交流群', 'QQ community group', 'QQ 交流群', 'QQ コミュニティグループ', 'QQ 커뮤니티 그룹', 'Nhóm cộng đồng QQ', 'Grupo de comunidad QQ', 'Grupo da comunidade QQ', 'Сообщество QQ', 'Groupe communautaire QQ', 'QQ-Community-Gruppe'),
qqLabel: _('QQ 群', 'QQ Group', 'QQ 群', 'QQ グループ', 'QQ 그룹', 'Nhóm QQ', 'Grupo de QQ', 'Grupo QQ', 'Группа QQ', 'Groupe QQ', 'QQ-Gruppe'),
wechatAlt: _('微信交流群', 'WeChat community group', '微信交流群', 'WeChat コミュニティグループ', '위챗 커뮤니티 그룹', 'Nhóm cộng đồng WeChat', 'Grupo de comunidad WeChat', 'Grupo da comunidade WeChat', 'Сообщество WeChat', 'Groupe communautaire WeChat', 'WeChat-Community-Gruppe'),
wechatLabel: _('微信群', 'WeChat Group', '微信群', 'WeChat グループ', '위챗 그룹', 'Nhóm WeChat', 'Grupo de WeChat', 'Grupo WeChat', 'Группа WeChat', 'Groupe WeChat', 'WeChat-Gruppe'),
douyinAlt: _('抖音交流群', 'Douyin community group', '抖音交流群', 'Douyin コミュニティグループ', '도우인 커뮤니티 그룹', 'Nhóm cộng đồng Douyin', 'Grupo de comunidad Douyin', 'Grupo da comunidade Douyin', 'Сообщество Douyin', 'Groupe communautaire Douyin', 'Douyin-Community-Gruppe'),
douyinLabel: _('抖音群', 'Douyin Group', '抖音群', 'Douyin グループ', '도우인 그룹', 'Nhóm Douyin', 'Grupo de Douyin', 'Grupo Douyin', 'Группа Douyin', 'Groupe Douyin', 'Douyin-Gruppe'),
feishuAlt: _('飞书交流群', 'Feishu community group', '飛書交流群', 'Feishu コミュニティグループ', '피슈 커뮤니티 그룹', 'Nhóm cộng đồng Feishu', 'Grupo de comunidad Feishu', 'Grupo da comunidade Feishu', 'Сообщество Feishu', 'Groupe communautaire Feishu', 'Feishu-Community-Gruppe'),
feishuLabel: _('飞书群', 'Feishu Group', '飛書群', 'Feishu グループ', '피슈 그룹', 'Nhóm Feishu', 'Grupo de Feishu', 'Grupo Feishu', 'Группа Feishu', 'Groupe Feishu', 'Feishu-Gruppe'),
dismissToday: _('今日不再弹窗', 'Do not show again today', '今日不再彈窗', '今日はもう表示しない', '오늘은 다시 보지 않기', 'Hôm nay không hiển thị lại', 'No volver a mostrar hoy', 'Não mostrar novamente hoje', 'Не показывать снова сегодня', 'Ne plus afficher aujourdhui', 'Heute nicht mehr anzeigen'),
}

View File

@@ -17,6 +17,21 @@ export default {
currentPassword: _('当前密码', 'Current Password', '目前密碼'),
currentPasswordPlaceholder: _('输入当前密码', 'Enter current password', '輸入目前密碼'),
defaultFilled: _('已自动填充默认密码,直接设置新密码即可', 'Default password auto-filled. Just set a new password.', '已自動填充預設密碼,直接設定新密碼即可'),
firstLoginHint: _('首次使用,默认密码已自动填充', 'First launch: the default password has been auto-filled.', '首次使用,預設密碼已自動填充'),
firstLoginChangeHint: _('登录后请前往「{security}」修改密码', 'After signing in, please open "{security}" to change the password.', '登入後請前往「{security}」修改密碼'),
appLocked: _('应用已锁定,请输入密码', 'The app is locked. Enter the password to continue.', '應用已鎖定,請輸入密碼'),
loginPrompt: _('请输入访问密码', 'Enter the access password', '請輸入訪問密碼'),
accessPasswordPlaceholder: _('访问密码', 'Access password', '訪問密碼'),
captchaPrompt: _('请先完成验证:', 'Please complete the verification first:', '請先完成驗證:'),
captchaPlaceholder: _('输入计算结果', 'Enter the result', '輸入計算結果'),
loginAction: _('登 录', 'Sign in', '登 入'),
loginSubmitting: _('登录中...', 'Signing in...', '登入中...'),
loginFailed: _('登录失败', 'Sign-in failed', '登入失敗'),
wrongCaptcha: _('验证码错误', 'Verification code is incorrect', '驗證碼錯誤'),
loginWrongPassword: _('密码错误', 'Incorrect password', '密碼錯誤'),
forgotPassword: _('忘记密码?', 'Forgot password?', '忘記密碼?'),
resetPasswordLocal: _('删除配置文件中的 {field} 字段即可重置:', 'Delete the {field} field from the config file to reset it:', '刪除設定檔中的 {field} 欄位即可重設:'),
resetPasswordRemote: _('编辑服务器上的配置文件,删除 {field} 字段后重启服务:', 'Edit the server config file, remove the {field} field, then restart the service:', '編輯伺服器上的設定檔,刪除 {field} 欄位後重新啟動服務:'),
newPassword: _('新密码', 'New Password', '新密碼'),
newPasswordPlaceholder: _('至少 6 位,不能纯数字', 'At least 6 chars, not all digits', '至少 6 位,不能純數字'),
confirmPassword: _('确认新密码', 'Confirm New Password', '確認新密碼'),

View File

@@ -13,6 +13,28 @@ export default {
refreshStatus: _('刷新状态', 'Refresh', '重新整理狀態'),
cliNotInstalled: _('OpenClaw CLI 未安装', 'OpenClaw CLI not installed', 'OpenClaw CLI 未安裝'),
installCliHint: _('请先安装 OpenClaw CLI:', 'Please install OpenClaw CLI first:', '請先安裝 OpenClaw CLI:'),
foreignGatewayDesc: _('检测到外部 Gateway 实例{pid},当前面板只会查看状态,不会直接停止或重启它。请先在「{settings}」中确认 CLI 绑定与安装路径。', 'External Gateway instance detected{pid}. The panel will only inspect its status and will not stop or restart it directly. Please confirm the CLI binding and install path in "{settings}" first.', '檢測到外部 Gateway 實例{pid},目前面板只會查看狀態,不會直接停止或重啟它。請先在「{settings}」中確認 CLI 綁定與安裝路徑。'),
foreignGatewayHint: _('检测到外部 Gateway建议先查看引导再决定是否切换绑定 CLI 或 OpenClaw 安装路径。', 'External Gateway detected. Review the guidance before deciding whether to switch the bound CLI or OpenClaw install path.', '檢測到外部 Gateway建議先查看引導再決定是否切換綁定 CLI 或 OpenClaw 安裝路徑。'),
guidanceTitleForeign: _('检测到外部 Gateway 实例', 'External Gateway detected', '檢測到外部 Gateway 實例'),
guidanceTitleForeignUnbound: _('检测到已运行的 Gateway', 'Gateway is already running', '檢測到已運行的 Gateway'),
guidanceTitleMultiInstall: _('检测到多个 OpenClaw 安装', 'Multiple OpenClaw installations detected', '檢測到多個 OpenClaw 安裝'),
guidanceTitleCheck: _('实例配置检查', 'Instance configuration check', '實例設定檢查'),
guidanceSummaryForeign: _('检测到当前端口上的 Gateway 不属于此面板实例。为了避免误停止、误重启或误接管,系统已阻止本次操作。', 'The Gateway on the current port does not belong to this panel instance. To avoid stopping, restarting, or taking over the wrong instance, this action was blocked.', '檢測到目前連接埠上的 Gateway 不屬於此面板實例。為避免誤停止、誤重啟或誤接管,系統已阻止本次操作。'),
guidanceSummaryForeignUnbound: _('当前端口上已经有 Gateway 在运行,而你还没有显式绑定 CLI。为了避免误接管面板会先保持只读查看状态建议先绑定你要管理的 OpenClaw 实例。', 'A Gateway is already running on the current port and you have not explicitly bound a CLI yet. To avoid taking over the wrong instance, the panel will stay read-only for now. Bind the OpenClaw instance you want to manage first.', '目前連接埠上已有 Gateway 在運行,而你尚未明確綁定 CLI。為避免誤接管面板會先維持唯讀查看狀態建議先綁定你要管理的 OpenClaw 實例。'),
guidanceSummaryMultiInstall: _('当前机器检测到多个 OpenClaw 安装。为了避免面板连到错误实例,建议先确认 CLI 绑定与 OpenClaw 安装路径。', 'Multiple OpenClaw installations were detected on this machine. To avoid connecting to the wrong instance, confirm the CLI binding and OpenClaw install path first.', '目前機器檢測到多個 OpenClaw 安裝。為避免面板連到錯誤實例,建議先確認 CLI 綁定與 OpenClaw 安裝路徑。'),
guidanceSummaryCheck: _('建议检查当前面板绑定的 CLI 与 OpenClaw 安装路径,确认当前实例与 Gateway 归属一致。', 'Check the CLI and OpenClaw install path currently bound to the panel, and confirm they match the intended Gateway instance.', '建議檢查目前面板綁定的 CLI 與 OpenClaw 安裝路徑,確認目前實例與 Gateway 歸屬一致。'),
guidanceSuggestionBindForeign: _('1. 如果这是你想管理的实例,请到「{settings}」里绑定正确的 CLI并把安装路径切到该实例对应目录。', '1. If this is the instance you want to manage, open "{settings}" and bind the correct CLI, then switch the install path to that instance.', '1. 如果這是你想管理的實例,請到「{settings}」裡綁定正確的 CLI並把安裝路徑切到該實例對應目錄。'),
guidanceSuggestionBindAutoDetected: _('1. 你目前还没有在「{settings}」里显式绑定 CLI。建议先去绑定要管理的 OpenClaw 实例,再执行启动、重启或停止。', '1. You have not explicitly bound a CLI in "{settings}" yet. Bind the OpenClaw instance you want to manage before trying start, restart, or stop.', '1. 你目前尚未在「{settings}」裡明確綁定 CLI。建議先去綁定要管理的 OpenClaw 實例,再執行啟動、重啟或停止。'),
guidanceSuggestionBind: _('1. 请到「{settings}」里确认当前绑定的 CLI 与 OpenClaw 安装路径是否指向你想管理的实例。', '1. Open "{settings}" and confirm the currently bound CLI and OpenClaw install path point to the instance you want to manage.', '1. 請到「{settings}」裡確認目前綁定的 CLI 與 OpenClaw 安裝路徑是否指向你想管理的實例。'),
guidanceSuggestionStopForeign: _('2. 如果这不是当前面板要管理的实例,请先手动关闭该 Gateway再回来执行启动/重启/停止操作。', '2. If this is not the instance the panel should manage, stop that Gateway manually before trying start/restart/stop here again.', '2. 如果這不是目前面板要管理的實例,請先手動關閉該 Gateway再回來執行啟動重啟停止操作。'),
guidanceSuggestionRefresh: _('2. 如果你刚切换过 CLI 或安装目录,建议保存后返回仪表盘刷新状态。', '2. If you just switched the CLI or install directory, save the changes and refresh the dashboard status.', '2. 如果你剛切換過 CLI 或安裝目錄,建議儲存後返回儀表盤重新整理狀態。'),
guidanceSuggestionInstallations: _('3. 下面列出了当前检测到的 OpenClaw 安装,通常可以据此判断应该绑定哪一个 CLI。', '3. The detected OpenClaw installations are listed below. They usually help you decide which CLI should be bound.', '3. 下方列出了目前檢測到的 OpenClaw 安裝,通常可據此判斷應該綁定哪一個 CLI。'),
guidanceNoInstallations: _('未检测到更多可用安装,请到「{settings}」中手动检查 CLI 绑定和安装路径。', 'No additional installations were detected. Please check the CLI binding and install path manually in "{settings}".', '未檢測到更多可用安裝,請到「{settings}」中手動檢查 CLI 綁定和安裝路徑。'),
guidanceHandlingTitle: _('建议处理方式', 'Recommended next steps', '建議處理方式'),
guidanceCurrentBindingTitle: _('面板当前 CLI 绑定', 'Panel CLI Binding', '面板目前 CLI 綁定'),
guidanceCliBindingAuto: _('未显式绑定(当前为自动检测)', 'Not explicitly bound (currently auto-detected)', '未明確綁定(目前為自動檢測)'),
guidanceBindCliBtn: _('去绑定 CLI', 'Bind CLI', '前往綁定 CLI'),
guidanceInstallationsTitle: _('已检测到的 OpenClaw 安装', 'Detected OpenClaw installations', '已檢測到的 OpenClaw 安裝'),
gwNotInstalled: _('Gateway 服务未安装', 'Gateway service not installed', 'Gateway 服務未安裝'),
gwInstalled: _('Gateway 服务已安装', 'Gateway service installed', 'Gateway 服務已安裝'),
gwUninstalled: _('Gateway 服务已卸载', 'Gateway service uninstalled', 'Gateway 服務已卸載'),
@@ -28,6 +50,40 @@ export default {
cancelled: _('已取消等待,可稍后刷新查看状态', 'Cancelled waiting. Refresh later to check status.', '已取消等待,可稍后重新整理查看狀態'),
currentVersion: _('当前版本', 'Current Version', '目前版本'),
dockerDeploy: _('Docker 部署', 'Docker Deploy'),
dockerManager: _('Docker 管理', 'Docker Manager', 'Docker 管理'),
dockerManagerHint: _('管理当前 Web 后端可访问的 Docker 节点与 OpenClaw 容器。适合本机 socket也支持填写远程 Docker API 节点。', 'Manage Docker nodes and OpenClaw containers reachable from the current Web backend. Works with the local socket and remote Docker API endpoints.', '管理目前 Web 後端可訪問的 Docker 節點與 OpenClaw 容器。適用於本機 socket也支援遠端 Docker API 節點。'),
dockerManagerUnavailable: _('当前环境没有可用的 Web 后端Docker 管理暂不可用。现阶段桌面版打包形态还未接入 Rust docker_* 命令;如需现在使用,请运行 Web/serve 模式。', 'A Web backend is not available in the current environment, so Docker management is temporarily unavailable. The packaged desktop build does not yet provide Rust docker_* commands. Use Web/serve mode for now.', '目前環境沒有可用的 Web 後端Docker 管理暫不可用。現階段桌面版打包形態尚未接入 Rust docker_* 命令;如需立即使用,請運行 Web/serve 模式。'),
dockerManagerLoadFailed: _('加载 Docker 概览失败', 'Failed to load Docker overview', '載入 Docker 概覽失敗'),
dockerRefresh: _('刷新 Docker', 'Refresh Docker', '重新整理 Docker'),
dockerAddNode: _('添加节点', 'Add Node', '新增節點'),
dockerPullAction: _('拉取镜像', 'Pull Image', '拉取映像'),
dockerCreateContainer: _('创建容器', 'Create Container', '建立容器'),
dockerNoContainers: _('暂无 OpenClaw 容器', 'No OpenClaw containers yet', '暫無 OpenClaw 容器'),
dockerNodeName: _('节点名称', 'Node Name', '節點名稱'),
dockerNodeEndpoint: _('Docker 地址', 'Docker Endpoint', 'Docker 位址'),
dockerNodeAdded: _('Docker 节点已添加', 'Docker node added', 'Docker 節點已新增'),
dockerNodeRemoved: _('Docker 节点已移除', 'Docker node removed', 'Docker 節點已移除'),
dockerRemoveNodeConfirm: _('确定移除 Docker 节点“{name}”吗?', 'Remove Docker node "{name}"?', '確定移除 Docker 節點「{name}」嗎?'),
dockerLocalNode: _('本机默认节点', 'Local default node', '本機預設節點'),
dockerOnline: _('在线', 'Online', '在線'),
dockerOffline: _('离线', 'Offline', '離線'),
dockerContainersLabel: _('容器', 'Containers', '容器'),
dockerPullTitle: _('拉取 Docker 镜像', 'Pull Docker Image', '拉取 Docker 映像'),
dockerPullDone: _('镜像拉取完成', 'Image pull completed', '映像拉取完成'),
dockerCreateTitle: _('创建 OpenClaw 容器', 'Create OpenClaw Container', '建立 OpenClaw 容器'),
dockerContainerNameLabel: _('容器名称', 'Container Name', '容器名稱'),
dockerImageLabel: _('镜像', 'Image', '映像'),
dockerTagLabel: _('标签', 'Tag', '標籤'),
dockerPanelPortLabel: _('面板端口', 'Panel Port', '面板連接埠'),
dockerGatewayPortLabel: _('Gateway 端口', 'Gateway Port', 'Gateway 連接埠'),
dockerUseVolume: _('持久化 ~/.openclaw 数据卷', 'Persist ~/.openclaw volume', '持久化 ~/.openclaw 資料卷'),
dockerContainerCreated: _('容器已创建并启动', 'Container created and started', '容器已建立並啟動'),
dockerContainerRemoved: _('容器已删除', 'Container removed', '容器已刪除'),
dockerRemoveContainerConfirm: _('确定删除容器“{name}”吗?如果容器仍在运行,会强制移除。', 'Delete container "{name}"? It will be force-removed if still running.', '確定刪除容器「{name}」嗎?如果容器仍在運行,會強制移除。'),
dockerDefaultImageHint: _('默认值来自「设置」里的 Docker 默认镜像。', 'Default comes from the Docker default image in Settings.', '預設值來自「設定」裡的 Docker 預設映像。'),
dockerPortOptionalHint: _('可选;留空则不映射该端口。多实例场景建议手动避开冲突。', 'Optional. Leave empty to avoid publishing that port. For multiple instances, choose ports manually to avoid conflicts.', '可選;留空則不映射該連接埠。多實例場景建議手動避開衝突。'),
invalidPort: _('无效端口: {value}', 'Invalid port: {value}', '無效連接埠: {value}'),
missingContainerId: _('缺少 containerId', 'Missing containerId', '缺少 containerId'),
chineseEdition: _('汉化优化版', 'Chinese Edition', '漢化最佳化版'),
officialEdition: _('官方原版', 'Official Edition'),
switchToChinese: _('切换到汉化版', 'Switch to Chinese Edition', '切換到漢化版'),

View File

@@ -6,8 +6,17 @@ export default {
networkProxy: _('网络代理', 'Network Proxy', '網路代理', 'ネットワークプロキシ', '네트워크 프록시', 'Proxy mạng', 'Proxy de red', 'Proxy de rede', 'Сетевой прокси', 'Proxy réseau', 'Netzwerk-Proxy'),
modelProxy: _('模型请求代理', 'Model Request Proxy', '模型請求代理', 'モデルリクエストプロキシ', '모델 요청 프록시', 'Proxy yêu cầu mô hình', 'Proxy de solicitudes de modelo', 'Proxy de solicitações de modelo', 'Прокси для запросов моделей', 'Proxy des requêtes de modèle', 'Modell-Anfrage-Proxy'),
npmRegistry: _('npm 源设置', 'npm Registry', 'npm 源設定', 'npm レジストリ', 'npm 레지스트리'),
openclawDir: _('OpenClaw 安装路径', 'OpenClaw Install Path', 'OpenClaw 安裝路徑', 'OpenClaw インストールパス', 'OpenClaw 설치 경로'),
openclawDir: _('OpenClaw 配置目录', 'OpenClaw Config Directory', 'OpenClaw 設定目錄', 'OpenClaw 設定ディレクトリ', 'OpenClaw 설정 디렉터리'),
openclawSearchPaths: _('额外 OpenClaw 搜索路径', 'Extra OpenClaw Search Paths', '額外 OpenClaw 搜尋路徑', '追加の OpenClaw 検索パス', '추가 OpenClaw 검색 경로'),
dockerDefaults: _('Docker 默认配置', 'Docker Defaults', 'Docker 預設配置', 'Docker デフォルト設定', 'Docker 기본 설정'),
openclawCli: _('OpenClaw CLI 绑定', 'OpenClaw CLI Binding', 'OpenClaw CLI 綁定', 'OpenClaw CLI バインド', 'OpenClaw CLI 바인딩'),
autostart: _('开机自启', 'Auto Start', '開機自啟', '自動起動', '자동 시작'),
autostartToggle: _('系统启动时自动运行 ClawPanel', 'Run ClawPanel automatically on system startup', '系統啟動時自動執行 ClawPanel', 'システム起動時に ClawPanel を自動実行', '시스템 시작 시 ClawPanel 자동 실행'),
autostartHint: _('开启后,电脑重启时 ClawPanel 会自动启动并检测 Gateway 状态', 'When enabled, ClawPanel will start automatically after reboot and check Gateway status', '開啟後,電腦重啟時 ClawPanel 會自動啟動並檢查 Gateway 狀態', '有効にすると、PC 再起動後に ClawPanel が自動起動し Gateway 状態を確認します', '활성화하면 PC 재시작 후 ClawPanel이 자동으로 시작되어 Gateway 상태를 확인합니다'),
autostartEnabled: _('已开启开机自启', 'Auto start enabled', '已開啟開機自啟', '自動起動を有効化しました', '자동 시작이 활성화되었습니다'),
autostartDisabled: _('已关闭开机自启', 'Auto start disabled', '已關閉開機自啟', '自動起動を無効化しました', '자동 시작이 비활성화되었습니다'),
autostartFailed: _('设置失败', 'Failed to update auto start', '設定失敗', '自動起動設定に失敗しました', '자동 시작 설정에 실패했습니다'),
autostartUnavailable: _('当前环境不支持开机自启', 'Auto start is unavailable in the current environment', '目前環境不支援開機自啟', '現在の環境では自動起動を利用できません', '현재 환경에서는 자동 시작을 사용할 수 없습니다'),
cliAutoDetect: _('自动检测(推荐)', 'Auto-detect (Recommended)', '自動檢測(推薦)', '自動検出(推奨)', '자동 감지 (권장)'),
cliBindHint: _('选择面板实际使用的 OpenClaw CLI适用于多版本共存场景', 'Select which OpenClaw CLI the panel should use, useful when multiple versions coexist', '選擇面板實際使用的 OpenClaw CLI適用於多版本共存場景', 'パネルが使用する OpenClaw CLI を選択、複数バージョン共存時に便利', '패널이 사용할 OpenClaw CLI 선택, 여러 버전 공존 시 유용'),
cliCurrent: _('当前使用', 'Currently used', '目前使用', '現在使用中', '현재 사용 중'),
@@ -33,16 +42,31 @@ export default {
configExists: _('配置文件存在', 'Config file exists', '設定檔案存在', '設定ファイルあり', '설정 파일 있음'),
configMissing: _('配置文件不存在', 'Config file not found', '設定檔案不存在', '設定ファイルが見つかりません', '설정 파일을 찾을 수 없음'),
currentPath: _('当前路径', 'Current path', '目前路徑', '現在のパス', '현재 경로'),
currentDefault: _('当前默认', 'Current default', '目前預設', '現在の既定値', '현재 기본값'),
customBadge: _('自定义', 'Custom', '自定義', 'カスタム', '사용자 정의'),
dirPlaceholder: _('留空使用默认路径 ~/.openclaw', 'Leave empty for default path ~/.openclaw', '留空使用預設路徑 ~/.openclaw', 'デフォルトパス ~/.openclaw を使用するには空欄のまま'),
searchPathsPlaceholder: _('每行填写一个 CLI 文件或安装目录,例如 D:\\Tools\\OpenClaw 或 E:\\AI\\openclaw.cmd', 'One CLI file or install directory per line, e.g. D:\\Tools\\OpenClaw or E:\\AI\\openclaw.cmd', '每行填寫一個 CLI 檔案或安裝目錄,例如 D:\\Tools\\OpenClaw 或 E:\\AI\\openclaw.cmd', 'CLI ファイルまたはインストールディレクトリを 1 行ずつ入力してください。例: D:\\Tools\\OpenClaw または E:\\AI\\openclaw.cmd'),
dockerEndpoint: _('Docker 守护进程地址', 'Docker Daemon Endpoint', 'Docker 守護進程地址', 'Docker デーモンのエンドポイント', 'Docker 데몬 엔드포인트'),
dockerEndpointPlaceholder: _('留空使用本机默认值,例如 tcp://192.168.1.20:2375 或 /var/run/docker.sock', 'Leave empty for local default, e.g. tcp://192.168.1.20:2375 or /var/run/docker.sock', '留空使用本機預設值,例如 tcp://192.168.1.20:2375 或 /var/run/docker.sock', '空欄でローカル既定値を使用。例: tcp://192.168.1.20:2375 または /var/run/docker.sock'),
dockerDefaultImage: _('默认 OpenClaw 镜像', 'Default OpenClaw Image', '預設 OpenClaw 映像', 'デフォルト OpenClaw イメージ', '기본 OpenClaw 이미지'),
dockerDefaultImagePlaceholder: _('例如 ghcr.io/qingchencloud/openclaw 或 1186258278/openclaw', 'e.g. ghcr.io/qingchencloud/openclaw or 1186258278/openclaw', '例如 ghcr.io/qingchencloud/openclaw 或 1186258278/openclaw', '例: ghcr.io/qingchencloud/openclaw または 1186258278/openclaw'),
resetDefault: _('恢复默认', 'Reset Default', '恢復預設', 'デフォルトに戻す', '기본값으로 복원', 'Khôi phục mặc định', 'Restaurar predeterminado', 'Restaurar padrão', 'Восстановить по умолчанию', 'Restaurer les paramètres par défaut', 'Standard wiederherstellen'),
dirHint: _('自定义 OpenClaw 配置目录路径。修改后需要重启面板生效。目标目录必须存在且包含 openclaw.json。', 'Custom OpenClaw config directory path. Restart required after changes. Target directory must exist and contain openclaw.json.', '自定義 OpenClaw 設定目錄路徑。修改后需要重啟面板生效。目標目錄必須存在且包含 openclaw.json。', 'カスタム OpenClaw 設定ディレクトリパス。変更後は再起動が必要です。対象ディレクトリは存在し、openclaw.json を含む必要があります。'),
searchPathsHint: _('可选。填写后,自动扫描和 CLI 自动解析会额外检查这些位置;支持 CLI 可执行文件或其所在安装目录,每行一个。', 'Optional. Auto-scan and CLI auto-resolution will also check these locations; supports either the CLI executable or its install directory, one per line.', '可選。填寫後,自動掃描和 CLI 自動解析會額外檢查這些位置;支援 CLI 可執行檔或其所在安裝目錄,每行一個。', '任意設定です。入力すると、自動スキャンと CLI 自動解決がこれらの場所も確認します。CLI 実行ファイルまたはそのインストールディレクトリを 1 行ずつ指定できます。'),
dockerDefaultsHint: _('用于 Docker 节点与容器操作的默认值。支持 tcp://、unix socket 或 Windows named pipe镜像名不含 tag拉取/创建时仍可单独指定 tag。', 'Defaults used by Docker node and container operations. Supports tcp://, unix socket, or Windows named pipe endpoints; image name should not include the tag because tag can still be specified separately.', '用於 Docker 節點與容器操作的預設值。支援 tcp://、unix socket 或 Windows named pipe映像名不含 tag拉取/建立時仍可另外指定 tag。', 'Docker ードとコンテナ操作で使う既定値です。tcp://、unix socket、Windows named pipe を指定できます。イメージ名には tag を含めず、pull / create 時に別途指定できます。'),
customPathSaved: _('自定义路径已保存', 'Custom path saved', '自定義路徑已儲存', 'カスタムパス保存済み', '사용자 정의 경로 저장됨'),
searchPathsSaved: _('额外搜索路径已保存', 'Extra search paths saved', '額外搜尋路徑已儲存', '追加の検索パスを保存しました', '추가 검색 경로가 저장되었습니다'),
searchPathsCleared: _('已清除额外搜索路径', 'Extra search paths cleared', '已清除額外搜尋路徑', '追加の検索パスをクリアしました', '추가 검색 경로가 지워졌습니다'),
dockerDefaultsSaved: _('Docker 默认配置已保存', 'Docker defaults saved', 'Docker 預設配置已儲存', 'Docker デフォルト設定を保存しました', 'Docker 기본 설정이 저장되었습니다'),
defaultRestored: _('已恢复默认路径', 'Default path restored', '已恢復預設路徑', 'デフォルトパスに戻しました', '기본 경로로 복원됨'),
restartConfirm: _('需要重启面板才能生效,是否立即重启?', 'Restart required for changes to take effect. Restart now?', '需要重啟面板才能生效,是否立即重啟?', '変更を反映するにはパネルの再起動が必要です。今すぐ再起動しますか?'),
restarting: _('正在重启...', 'Restarting...', '正在重啟...', '再起動中...', '재시작 중...', 'Đang khởi động lại...', 'Reiniciando...', 'Reiniciando...', 'Перезапуск...', 'Redémarrage...', 'Wird neugestartet...'),
restartFailed: _('自动重启失败,请手动关闭后重新打开', 'Auto-restart failed, please close and reopen manually', '自動重啟失敗,請手動關閉后重新開啟', '自動再起動に失敗しました。手動で閉じて再度開いてください'),
effectNextLaunch: _('下次启动时生效', 'will take effect on next launch', '下次啟動時生效', '次回起動時に有効'),
gatewayServiceRefreshConfirm: _('检测到 macOS 上的 Gateway 服务定义可能仍指向旧的 CLI 或安装路径。是否立即重装 Gateway 服务以刷新当前绑定?这会短暂停止并重新安装服务。', 'The macOS Gateway service definition may still point to the old CLI or install path. Reinstall the Gateway service now to refresh the current binding? This will briefly stop and reinstall the service.', '檢測到 macOS 上的 Gateway 服務定義可能仍指向舊的 CLI 或安裝路徑。是否立即重裝 Gateway 服務以刷新目前綁定?這會短暫停止並重新安裝服務。'),
gatewayServiceRefreshing: _('正在刷新 Gateway 服务定义...', 'Refreshing Gateway service definition...', '正在刷新 Gateway 服務定義...'),
gatewayServiceRefreshed: _('Gateway 服务定义已刷新', 'Gateway service definition refreshed', 'Gateway 服務定義已刷新'),
gatewayServiceRefreshFailed: _('刷新 Gateway 服务定义失败', 'Failed to refresh Gateway service definition', '刷新 Gateway 服務定義失敗'),
proxyUrlInvalid: _('代理地址必须以 http:// 或 https:// 开头', 'Proxy URL must start with http:// or https://', '代理位址必須以 http:// 或 https:// 開頭', 'プロキシ URL は http:// または https:// で始まる必要があります'),
testingProxy: _('正在测试代理连通性...', 'Testing proxy connectivity...', '正在測試代理連通性...', 'プロキシ接続をテスト中...'),
proxyOk: _('代理连通HTTP {status},耗时 {ms}ms→ {target}', 'Proxy connected (HTTP {status}, {ms}ms) → {target}', '代理連通HTTP {status},耗時 {ms}ms→ {target}', 'プロキシ接続成功HTTP {status}、{ms}ms→ {target}', '프록시 연결됨 (HTTP {status}, {ms}ms) → {target}'),

View File

@@ -13,29 +13,34 @@ export default {
recheckAfterInstall: _('安装后点击「重新检测」', 'Click "Re-detect" after installation', '安裝后点擊「重新檢測」', 'インストール後「再検出」をクリック'),
nodeInstalledButNotDetected: _('已经装了但检测不到?', 'Already installed but not detected?', '已經裝了但檢測不到?', 'インストール済みなのに検出されない?'),
macNodeHint: _('macOS 上从 Finder 启动可能找不到 Node.js。试试关掉 ClawPanel 后从终端启动:', 'On macOS, launching from Finder may not find Node.js. Try closing ClawPanel and launching from terminal:', 'macOS 上從 Finder 啟動可能找不到 Node.js。試試關掉 ClawPanel 后從終端啟動:'),
winNodeHint: _('安装 Node.js 后点击「重新检测」或使用下方「自动扫描」,无需重启。', 'After installing Node.js, click "Re-detect" or use "Auto Scan" below, no restart needed.', '安裝 Node.js 后擊「重新檢測」或使用下方「自動掃描」,無需重啟。'),
winNodeHint: _('安装 Node.js 后点击「重新检测」或直接使用「自动扫描」,无需重启。', 'After installing Node.js, click "Re-detect" or use "Auto Scan" directly, no restart needed.', '安裝 Node.js 后擊「重新檢測」或直接使用「自動掃描」,無需重啟。'),
scanNodeBtn: _('自动扫描', 'Auto Scan', '自動掃描', '自動スキャン', '자동 스캔'),
orManualPath: _('或手动指定路径:', 'Or specify path manually:', '或手動指定路徑:', 'またはパスを手動指定:'),
checkPathBtn: _('检测', 'Check', '檢測', '検出'),
stepGit: _('Git 版本管理', 'Git Version Control', '', 'Git バージョン管理', 'Git 버전 관리', 'Git', 'Git', 'Git', 'Git', 'Git', 'Git'),
gitHttpsConfigured: _('已自动配置 Git 使用 HTTPS避免 SSH 连接问题)', 'Auto-configured Git to use HTTPS (avoids SSH connection issues)', '已自動設定 Git 使用 HTTPS避免 SSH 連線問題)'),
stepGitHint: _('部分依赖需要 Git 下载源码。点击下方按钮自动安装如果失败手动安装。', 'Some dependencies require Git to download source code. Click the button below to auto-install, or install manually if it fails.', '部分依需要 Git 下載源碼。点擊下方按鈕自動安裝如果失敗手動安裝。', '一部の依存関係には Git が必要です。下のボタンで自動インストールするか、手動でインストールしてください。'),
stepGitHint: _('部分依赖需要 Git 下载源码。可直接使用这里的自动安装按钮;如果失败,再手动安装即可。', 'Some dependencies require Git to download source code. You can use the install button here, and install manually only if it fails.', '部分依需要 Git 下載源碼。可直接使用這裡的自動安裝按鈕;如果失敗,再手動安裝即可。', '一部の依存関係には Git が必要です。ここにあるインストールボタンを使い、失敗した場合のみ手動でインストールしてください。'),
autoInstallGitBtn: _('一键安装 Git', 'Auto Install Git', '一鍵安裝 Git', 'Git をワンクリックインストール', 'Git 원클릭 설치'),
manualDownload: _('手动下载', 'Manual Download', '手動下載', '手動ダウンロード', '수동 다운로드'),
gitOptionalHint: _('<strong>没有 Git 也能安装?</strong> 大部分情况下可以,但个别依赖可能需要 Git。建议安装以避免问题。', '<strong>Can I install without Git?</strong> Usually yes, but some dependencies may require Git. Recommended to install to avoid issues.', '<strong>沒有 Git 也能安裝?</strong> 大部分情況下可以,但個別依赖可能需要 Git。建議安裝以避免問題。'),
statusReady: _('已就绪', 'Ready', '已就緒', '準備完了'),
statusActionNeeded: _('需要处理', 'Action needed', '需要處理', '対応が必要'),
detectedPathLabel: _('检测路径', 'Detected Path', '檢測路徑', '検出パス'),
detectedFromLabel: _('检测来源', 'Detected From', '檢測來源', '検出元'),
cliAvailable: _('CLI 可用', 'CLI available'),
cliAheadWarning: _('检测到当前本地 OpenClaw {current} 高于当前面板推荐稳定版 {recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。', 'Detected local OpenClaw {current} is ahead of the panel\'s recommended stable version {recommended}. There may be compatibility or stability risks. Consider rolling back to the recommended version on the About page.', '檢測到目前本地 OpenClaw {current} 高於目前面板推薦穩定版 {recommended},可能存在相容或穩定性風險。建議稍后到「關於」頁回退到推薦版。'),
stepConfig: _('配置文件', 'Config File', '設定檔案', '設定ファイル', '설정 파일', 'Tệp cấu hình', 'Archivo de configuración', 'Arquivo de configuração', 'Файл конфигурации', 'Fichier de configuration', 'Konfigurationsdatei'),
configAt: _('配置文件位于 {path}', 'Config file at {path}', '設定檔案位於 {path}', '設定ファイル: {path}'),
configMissing: _('配置文件不存在,点击下方按钮自动创建默认配置。', 'Config file not found. Click the button below to create default config.', '設定檔案不存在,点擊下方按鈕自動建立預設設定。', '設定ファイルが存在しません。下のボタンでデフォルト設定を自動作成します。', '설정 파일이 존재하지 않습니다'),
configMissing: _('配置文件不存在,可直接创建默认配置。', 'Config file not found. Create the default config.', '設定檔案不存在,可直接建立預設設定。', '設定ファイルが存在しません。ここにあるボタンでデフォルト設定を作成できます。'),
initConfigLabel: _('一键初始化配置', 'Initialize Config', '一鍵初始化設定', '設定をワンクリック初期化', '설정 원클릭 초기화'),
customDirTitle: _('自定义 OpenClaw 安装路径', 'Custom OpenClaw Install Path', '自定義 OpenClaw 安裝路徑', 'カスタム OpenClaw インストールパス', '사용자 정의 OpenClaw 설치 경로'),
customDirHint: _('如果 OpenClaw 安装在非默认目录(如 <code>E:\\数据\\AI\\.openclaw</code>),可在此指定。留空则使用默认路径。', 'If OpenClaw is installed in a non-default directory, specify it here. Leave empty to use the default path.', '如果 OpenClaw 安裝在非預設目錄(如 <code>E:\\資料\\AI\\.openclaw</code>),可在此指定。留空則使用預設路徑。', 'OpenClaw がデフォルト以外のディレクトリにインストールされている場合、ここで指定できます。空欄の場合はデフォルトパスを使用します。'),
customDirPlaceholder: _('例如 E:\\\\数据\\\\AI\\\\.openclaw', 'e.g. E:\\\\Data\\\\AI\\\\.openclaw', '例如 E:\\\\資料\\\\AI\\\\.openclaw'),
customDirTitle: _('自定义 OpenClaw 配置目录', 'Custom OpenClaw Config Directory', '自定義 OpenClaw 設定目錄', 'カスタム OpenClaw 設定ディレクトリ', '사용자 정의 OpenClaw 설정 디렉터리'),
customDirHint: _('如果你的 <code>openclaw.json</code> 不在默认目录(如 <code>E:\\数据\\AI\\.openclaw</code>),可在此指定配置目录。留空则使用默认路径。', 'If your <code>openclaw.json</code> is not in the default directory, specify the config directory here. Leave empty to use the default path.', '如果你的 <code>openclaw.json</code> 不在預設目錄(如 <code>E:\\資料\\AI\\.openclaw</code>),可在此指定設定目錄。留空則使用預設路徑。', 'もし <code>openclaw.json</code> がデフォルト以外の場所にある場合、ここで設定ディレクトリを指定できます。空欄ならデフォルトパスを使用します。'),
customDirNotice: _('这里只影响配置文件与状态目录,不会替代上方选择的 OpenClaw CLI 可执行文件。', 'This only changes the config/state directory and does not replace the OpenClaw CLI executable selected above.', '這裡只會影響設定檔與狀態目錄,不會取代上方選擇的 OpenClaw CLI 可執行檔。', 'ここで変更されるのは設定/状態ディレクトリのみで、上で選択した OpenClaw CLI 実行ファイルは置き換えられません。'),
customDirPlaceholder: _('例如 E:\\数据\\AI\\.openclaw', 'e.g. E:\\Data\\AI\\.openclaw', '例如 E:\\資料\\AI\\.openclaw'),
saveBtn: _('保存', 'Save', '儲存', '保存', '저장', 'Lưu', 'Guardar', 'Salvar', 'Сохранить', 'Enregistrer', 'Speichern'),
resetDefaultBtn: _('恢复默认', 'Reset Default', '恢復預設', 'デフォルトに戻す', '기본값으로 복원', 'Khôi phục mặc định', 'Restaurar predeterminado', 'Restaurar padrão', 'Восстановить по умолчанию', 'Restaurer les paramètres par défaut', 'Standard wiederherstellen'),
aiAssistant: _('晴辰助手', 'AI Assistant', '', 'AI アシスタント', 'AI 어시스턴트', 'Trợ lý AI', 'Asistente IA', 'Assistente IA', 'ИИ-ассистент', 'Assistant IA', 'KI-Assistent'),
aiAssistantDesc: _('遇到安装问题AI 助手可以帮你诊断和解决。配置好模型后,点击下方按钮', 'Having installation issues? AI Assistant can help diagnose and solve them. After configuring a model, click the button below', '遇到安裝問題AI 助手可以幫你斷和解決。設定好模型后,点擊下方按鈕', 'インストールの問題がありますかAI アシスタントが診断と解決をお手伝いします。'),
aiAssistantDesc: _('遇到安装问题AI 助手可以帮你诊断和解决。配置好模型后,点击这里的按钮', 'Having installation issues? AI Assistant can help diagnose and solve them. After configuring a model, use the buttons here', '遇到安裝問題AI 助手可以幫你斷和解決。設定好模型后,點擊這裡的按鈕', 'インストールの問題がありますかAI アシスタントが診断と解決をお手伝いします。モデル設定後は、ここにあるボタンを使ってください。'),
aiAssistantDescProblem: _(',当前问题会自动发送给 AI 分析', ', current issues will be auto-sent to AI for analysis', ',目前問題會自動發送给 AI 分析'),
openAiAssistant: _('打开 AI 助手', 'Open AI Assistant', '開啟 AI 助手', 'AI アシスタントを開く', 'AI 어시스턴트 열기', 'Mở trợ lý AI', 'Abrir asistente IA', 'Abrir assistente IA', 'Открыть ИИ-ассистента', 'Ouvrir l\'assistant IA', 'KI-Assistent öffnen'),
askAiHelp: _('让 AI 帮我解决', 'Ask AI for Help', '讓 AI 幫我解決', 'AI に解決してもらう'),
@@ -67,22 +72,53 @@ export default {
methodHintGithub: _('从 GitHub Releases 下载独立安装包。CDN 不可用时的备选方案。', 'Downloads standalone package from GitHub Releases. Fallback when CDN is unavailable.', '從 GitHub Releases 下載獨立安裝包。CDN 不可用時的備選方案。'),
methodHintNpm: _('传统的 npm install 方式,需要本机已安装 Node.js 和 npm且网络能访问 npm 仓库。', 'Traditional npm install, requires local Node.js, npm, and network access to npm registry.', '傳統的 npm install 方式,需要本機已安裝 Node.js 和 npm且網路能訪問 npm 倉庫。'),
envHintTitle: _('找不到已安装的 OpenClaw', 'Can\'t find installed OpenClaw?', '找不到已安裝的 OpenClaw'),
envHintDesc: _('ClawPanel 桌面版只能管理<strong>本机</strong>安装的 OpenClaw。以下环境中的安装无法被检测到:', 'ClawPanel desktop can only manage <strong>locally</strong> installed OpenClaw. Installations in these environments cannot be detected:', 'ClawPanel 桌面版只能管理<strong>本機</strong>安裝的 OpenClaw。以下環境中的安裝無法被檢測到:'),
envHintDesc: _('ClawPanel 桌面版只能直接管理<strong>当前 Windows / macOS / Linux 本机</strong>的 OpenClaw。若你的 OpenClaw 实际装在 WSL、Docker 或远程服务器里,请把下面内容视为<strong>高级替代方案</strong>,而不是普通本机安装的默认路径。', 'ClawPanel desktop can only directly manage OpenClaw installed on the <strong>current local Windows / macOS / Linux machine</strong>. If your OpenClaw actually lives in WSL, Docker or a remote server, treat the options below as <strong>advanced alternatives</strong>, not the default local setup path.', 'ClawPanel 桌面版只能直接管理<strong>目前 Windows / macOS / Linux 本機</strong>的 OpenClaw。若你的 OpenClaw 實際裝在 WSL、Docker 或遠端伺服器裡,請把下面內容視為<strong>進階替代方案</strong>,而不是普通本機安裝的預設路徑。'),
envHintWsl: _('WSL (Windows 子系统)', 'WSL (Windows Subsystem for Linux)', 'WSL (Windows 子系統)'),
envHintWslDesc: _('OpenClaw 装在 WSL 里Windows 侧无法访问', 'OpenClaw in WSL is not accessible from Windows side', 'OpenClaw 裝在 WSL 里Windows 側無法訪問'),
envHintDocker: _('Docker 容器', 'Docker Container'),
envHintDockerDesc: _('容器内的安装与宿主机隔离', 'Container installations are isolated from host', '容器內的安裝與宿主機隔離'),
envHintRemote: _('远程服务器', 'Remote Server', '遠程伺服器'),
envHintRemoteDesc: _('安装在其他机器上', 'Installed on another machine', '安裝在其他機器上'),
envHintInstallManage: _('在对应环境中安装管理面板', 'Install management panel in the corresponding environment', '在对應環境中安裝管理面板'),
envHintLocalReinstall: _('或者,你也可以在本机重新安装 OpenClaw使用下方的「一键安装」。', 'Or, reinstall OpenClaw locally using the install button below.', '或者,你也可以在本機重新安裝 OpenClaw使用下方的「一鍵安裝」。'),
envHintInstallManage: _('高级替代方案:在对应环境里使用 ClawPanel Web 版', 'Advanced alternative: use ClawPanel Web inside that environment', '進階替代方案:在對應環境裡使用 ClawPanel Web 版'),
envHintLocalReinstall: _('如果你要管理的是当前这台电脑里的 OpenClaw更建议直接使用本页的「安装 OpenClaw」卡片或在上方手动绑定 CLI 路径。', 'If you want to manage OpenClaw on this computer, use the "Install OpenClaw" card on this page or bind the CLI path above.', '如果你要管理的是目前這台電腦裡的 OpenClaw更建議直接使用本頁的「安裝 OpenClaw」卡片或在上方手動綁定 CLI 路徑。'),
searchOpenclawTitle: _('本机已经装了 OpenClaw', 'Already installed OpenClaw locally?', '本機已經安裝 OpenClaw'),
searchOpenclawDesc: _('可以主动搜索本机常见安装位置,并从结果中选择当前要管理的 OpenClaw CLI。', 'You can actively search common local install locations and choose which OpenClaw CLI this panel should manage.', '可以主動搜尋本機常見安裝位置,並從結果中選擇目前要管理的 OpenClaw CLI。'),
searchOpenclawBtn: _('搜索 OpenClaw', 'Search OpenClaw', '搜尋 OpenClaw'),
searchOpenclawAdvancedTitle: _('高级检测 / 手动绑定', 'Advanced Detection / Manual Binding', '進階檢測 / 手動綁定'),
searchOpenclawAdvancedHint: _('只有常规搜索找不到时,再展开这里补充搜索路径或手动绑定 CLI。', 'Expand this only if the normal search cannot find your CLI, then add extra paths or bind it manually.', '只有一般搜尋找不到時,再展開這裡補充搜尋路徑或手動綁定 CLI。'),
searchOpenclawExtraPathsLabel: _('可选:补充额外搜索路径', 'Optional: add extra search paths', '可選:補充額外搜尋路徑'),
searchOpenclawExtraPathsPlaceholder: _('每行一个 CLI 文件或安装目录,例如 D:\\Tools\\OpenClaw 或 E:\\AI\\openclaw.cmd', 'One CLI file or install directory per line, e.g. D:\\Tools\\OpenClaw or E:\\AI\\openclaw.cmd', '每行一個 CLI 檔案或安裝目錄,例如 D:\\Tools\\OpenClaw 或 E:\\AI\\openclaw.cmd'),
searchOpenclawExtraPathsHint: _('保存后,自动扫描和 CLI 自动解析都会额外检查这些位置。', 'After saving, auto-scan and CLI auto-resolution will also check these locations.', '儲存後,自動掃描和 CLI 自動解析都會額外檢查這些位置。'),
searchOpenclawExtraPathsSave: _('保存搜索路径', 'Save Search Paths', '儲存搜尋路徑'),
searchOpenclawExtraPathsSaved: _('额外搜索路径已保存', 'Extra search paths saved', '額外搜尋路徑已儲存'),
searchOpenclawExtraPathsCleared: _('已清除额外搜索路径', 'Extra search paths cleared', '已清除額外搜尋路徑'),
dockerDefaultsTitle: _('可选:预设 Docker 默认配置', 'Optional: preset Docker defaults', '可選:預設 Docker 預設配置'),
dockerDefaultsHint: _('用于后续 Docker 节点与容器管理。留空时会回退到本机默认 Docker socket 和默认镜像。', 'Used by later Docker node and container management. Leave empty to fall back to the local default Docker socket and image.', '用於後續 Docker 節點與容器管理。留空時會回退到本機預設 Docker socket 與預設映像。'),
dockerCurrentEndpoint: _('当前默认地址', 'Current default endpoint', '目前預設地址'),
dockerCurrentImage: _('当前默认镜像', 'Current default image', '目前預設映像'),
dockerEndpointPlaceholder: _('例如 tcp://192.168.1.20:2375 或 /var/run/docker.sock', 'e.g. tcp://192.168.1.20:2375 or /var/run/docker.sock', '例如 tcp://192.168.1.20:2375 或 /var/run/docker.sock'),
dockerDefaultImagePlaceholder: _('例如 ghcr.io/qingchencloud/openclaw 或 1186258278/openclaw', 'e.g. ghcr.io/qingchencloud/openclaw or 1186258278/openclaw', '例如 ghcr.io/qingchencloud/openclaw 或 1186258278/openclaw'),
dockerDefaultsSave: _('保存 Docker 默认值', 'Save Docker Defaults', '儲存 Docker 預設值'),
dockerDefaultsSaved: _('Docker 默认配置已保存', 'Docker defaults saved', 'Docker 預設配置已儲存'),
searchOpenclawHint: _('搜索结果里点击「使用」即可绑定当前 CLI若随后仍提示配置文件缺失请到右侧配置卡里填写自定义 OpenClaw 配置目录。', 'Click "Use" on a result to bind that CLI. If config is still missing afterwards, set the custom OpenClaw config directory in the config card on the right.', '在搜尋結果中點擊「使用」即可綁定目前 CLI若之後仍提示設定檔缺失請到右側設定卡中填寫自定義 OpenClaw 設定目錄。'),
searchOpenclawScanning: _('正在搜索常见安装位置...', 'Searching common install locations...', '正在搜尋常見安裝位置...'),
searchOpenclawEmpty: _('没有在常见路径中找到 OpenClaw。你可以继续手动填写 CLI 路径,或直接使用本页的「安装 OpenClaw」卡片。', 'No OpenClaw installation was found in common paths. You can keep entering the CLI path manually, or use the "Install OpenClaw" card on this page.', '沒有在常見路徑中找到 OpenClaw。你可以繼續手動填寫 CLI 路徑,或直接使用本頁的「安裝 OpenClaw」卡片。'),
searchOpenclawUsing: _('使用中...', 'Using...', '使用中...'),
searchOpenclawSelectSuccess: _('已选择该 OpenClaw CLI正在重新检测...', 'Selected this OpenClaw CLI, re-detecting...', '已選擇此 OpenClaw CLI正在重新檢測...'),
searchOpenclawSelectFailed: _('选择失败: {err}', 'Selection failed: {err}', '選擇失敗: {err}'),
searchOpenclawManualLabel: _('或者手动指定已有 OpenClaw CLI / 安装目录', 'Or enter an existing OpenClaw CLI / install directory manually', '或者手動指定既有 OpenClaw CLI / 安裝目錄', 'または既存の OpenClaw CLI / インストールディレクトリを手動指定'),
searchOpenclawManualPlaceholder: _('例如 C:\\Users\\你的用户名\\AppData\\Roaming\\npm\\openclaw.cmd 或安装目录', 'e.g. C:\\Users\\YourName\\AppData\\Roaming\\npm\\openclaw.cmd or an install directory', '例如 C:\\Users\\你的用戶名\\AppData\\Roaming\\npm\\openclaw.cmd 或安裝目錄', '例: C:\\Users\\YourName\\AppData\\Roaming\\npm\\openclaw.cmd またはインストールディレクトリ'),
searchOpenclawManualBtn: _('检测并使用', 'Detect & Use', '檢測並使用', '検出して使用'),
searchOpenclawManualHint: _('支持填写可执行文件完整路径,也支持直接填写包含 openclaw 的安装目录。', 'You can enter either the full executable path or an installation directory that contains openclaw.', '支援填寫可執行檔完整路徑,也支援直接填寫包含 openclaw 的安裝目錄。', '実行ファイルのフルパス、または openclaw を含むインストールディレクトリのどちらも指定できます。'),
searchOpenclawManualSaved: _('已绑定该 OpenClaw CLI正在重新检测...', 'Bound this OpenClaw CLI, re-detecting...', '已綁定此 OpenClaw CLI正在重新檢測...', 'この OpenClaw CLI を関連付けました。再検出しています...'),
searchOpenclawManualNotFound: _('在这个位置没有找到可用的 OpenClaw CLI请确认路径正确。', 'No usable OpenClaw CLI was found at this location. Please verify the path.', '在這個位置沒有找到可用的 OpenClaw CLI請確認路徑正確。', 'この場所に利用可能な OpenClaw CLI が見つかりませんでした。パスを確認してください。'),
wslWebHint: _('WSL 中使用 Web 版:', 'Use Web version in WSL:'),
wslWebDesc: _('打开 WSL 终端,一键部署 ClawPanel Web 版:', 'Open WSL terminal, one-click deploy ClawPanel Web version:', '開啟 WSL 終端,一鍵部署 ClawPanel Web 版:'),
wslWebPostDeploy: _('部署后在浏览器访问 WSL 的 IP 即可管理。', 'After deployment, access via WSL IP in your browser.', '部署后在瀏覽器訪問 WSL 的 IP 即可管理。'),
wslWebDesc: _('如果你的 OpenClaw 本来就运行在 WSL 里,可打开 WSL 终端部署 ClawPanel Web 版:', 'If your OpenClaw already runs inside WSL, open a WSL terminal and deploy ClawPanel Web there:', '如果你的 OpenClaw 本來就運行在 WSL 裡,可開啟 WSL 終端部署 ClawPanel Web 版:'),
wslWebPostDeploy: _('部署完成后,再从浏览器访问 WSL 的 IP 管理该环境。', 'After deployment, access the WSL IP from your browser to manage that environment.', '部署完成後,再從瀏覽器訪問 WSL 的 IP 管理該環境。'),
dockerHint: _('Docker 容器中使用:', 'Use in Docker container:'),
dockerDesc: _('在容器内安装 OpenClaw + ClawPanel Web 版:', 'Install OpenClaw + ClawPanel Web in container:', '在容器內安裝 OpenClaw + ClawPanel Web 版:'),
dockerDesc: _('如果你的 OpenClaw 本来就运行在容器里,可在容器内安装 OpenClaw + ClawPanel Web 版:', 'If your OpenClaw already runs inside a container, install OpenClaw + ClawPanel Web there:', '如果你的 OpenClaw 本來就運行在容器裡,可在容器內安裝 OpenClaw + ClawPanel Web 版:'),
remoteHint: _('远程服务器:', 'Remote server:', '遠程伺服器:'),
remoteDesc: _('SSH 登录服务器后执行:', 'SSH into server and run:', 'SSH 登入伺服器執行:'),
remoteDesc: _('如果 OpenClaw 装在远程服务器上,请 SSH 登录服务器后执行:', 'If OpenClaw is installed on a remote server, SSH into that server and run:', '如果 OpenClaw 裝在遠程伺服器上,請 SSH 登入伺服器執行:'),
domesticMirror: _('国内用户如无法访问 GitHub', 'For users in China who cannot access GitHub:', '國內使用者如無法訪問 GitHub'),
domesticMirrorShort: _('国内镜像:', 'China mirror:', '國內鏡像:'),
promptNodeMissing: _('Node.js 未安装或未检测到', 'Node.js not installed or not detected', 'Node.js 未安裝或未檢測到'),

View File

@@ -471,6 +471,7 @@
"noDesc": "无描述",
"default": "默认",
"backup": "备份",
"detail": "详情",
"edit": "编辑",
"delete": "删除",
"labelName": "名称:",
@@ -504,7 +505,102 @@
"deleteFailed": "删除失败",
"backingUp": "正在备份 Agent「{id}」...",
"backupDone": "备份完成: {file}",
"backupFailed": "备份失败"
"backupFailed": "备份失败",
"detailHint": "点击卡片空白区域或“详情”按钮,进入新的 Agent 详情页。"
},
"agentDetail": {
"back": "← 返回 Agent 列表",
"tabOverview": "概览",
"tabFiles": "文件",
"tabChannels": "渠道",
"tabTools": "工具",
"tabSkills": "技能",
"defaultAgent": "默认 Agent",
"basicInfo": "基本信息",
"agentId": "Agent ID",
"name": "名称",
"emoji": "表情",
"workspace": "工作区",
"notSet": "未设置",
"modelConfig": "模型配置",
"primaryModel": "主模型",
"fallbackModels": "备选模型",
"addFallback": "+ 添加备选",
"noFallback": "未配置备选模型(主模型不可用时无法切换)",
"removeFallback": "移除",
"thinkingLevel": "推理级别",
"thinkingOff": "关闭",
"thinkingMinimal": "最小",
"thinkingLow": "低",
"thinkingMedium": "中",
"thinkingHigh": "高",
"thinkingXhigh": "极高",
"thinkingAdaptive": "自适应",
"saveOverview": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置已保存",
"saveFailed": "保存失败",
"filesTitle": "Bootstrap 文件",
"filesDesc": "Agent 工作区中的核心配置文件,定义 Agent 的行为、身份和记忆",
"fileAgents": "Agent 规则",
"fileAgentsDesc": "定义 Agent 操作规则、红线和会话指令",
"fileSoul": "灵魂/人格",
"fileSoulDesc": "定义 Agent 的人格、背景和行为准则",
"fileTools": "工具白名单",
"fileToolsDesc": "声明 Agent 可使用的工具列表",
"fileIdentity": "身份信息",
"fileIdentityDesc": "Agent 的名称、表情和头像配置",
"fileUser": "用户上下文",
"fileUserDesc": "提供给 Agent 的用户背景信息",
"fileHeartbeat": "心跳指令",
"fileHeartbeatDesc": "定期执行的心跳/巡检任务描述",
"fileBootstrap": "初始化引导",
"fileBootstrapDesc": "首次启动时的引导设置文件",
"fileMemory": "记忆存储",
"fileMemoryDesc": "Agent 的持久记忆数据(建议只读)",
"fileExists": "已创建",
"fileMissing": "未创建",
"fileEdit": "编辑",
"fileView": "查看",
"fileCreate": "创建",
"fileSize": "大小",
"fileUpdated": "更新时间",
"editFileTitle": "编辑 {name}",
"fileSaved": "文件已保存",
"fileSaveFailed": "文件保存失败",
"fileCreated": "文件已创建",
"channelsTitle": "渠道绑定",
"channelsDesc": "管理此 Agent 绑定的消息渠道",
"addBinding": "+ 添加绑定",
"noBindings": "此 Agent 尚未绑定任何渠道",
"removeBinding": "解绑",
"bindingChannel": "渠道",
"bindingAccount": "账号",
"bindingType": "类型",
"bindingRemoved": "已解除绑定",
"bindingAdded": "已添加绑定",
"bindingFailed": "绑定操作失败",
"selectChannel": "选择渠道",
"accountOptional": "账号 ID可选",
"loadFailed": "加载失败",
"toolsTitle": "工具权限",
"toolsDesc": "配置 Agent 可用工具的基础 profile 与额外 allow / deny 规则。",
"toolProfile": "工具配置模板",
"toolAllow": "显式允许",
"toolAllowHint": "逗号或换行分隔。设置后会作为基础 allowlist。",
"toolAlsoAllow": "追加允许",
"toolAlsoAllowHint": "在 profile 基础上额外开放的工具。",
"toolDeny": "显式禁止",
"toolDenyHint": "优先级高于 allow/profile。",
"saveTools": "保存工具配置",
"toolsSaved": "工具配置已保存",
"skillsTitle": "技能白名单",
"skillsDesc": "勾选当前 Agent 允许使用的 Skills留空表示不启用技能白名单。",
"saveSkills": "保存技能配置",
"skillsSaved": "技能配置已保存",
"noSkills": "未获取到可用 Skills",
"skillDisabled": "已禁用",
"skillUnavailable": "环境未满足"
},
"gateway": {
"title": "Gateway 配置",

View File

@@ -13,8 +13,9 @@ import { wsClient } from './lib/ws-client.js'
import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from './lib/tauri-api.js'
import { version as APP_VERSION } from '../package.json'
import { statusIcon } from './lib/icons.js'
import { isForeignGatewayError, showGatewayConflictGuidance } from './lib/gateway-ownership.js'
import { tryShowEngagement } from './components/engagement.js'
import { initI18n } from './lib/i18n.js'
import { initI18n, t } from './lib/i18n.js'
// 样式
import './style/variables.css'
@@ -37,6 +38,12 @@ function escapeHtml(str) {
return (str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
async function openGatewayConflict(error = null) {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
await showGatewayConflictGuidance({ error, service: gw })
}
// === 访问密码保护Web + 桌面端通用) ===
const isTauri = !!window.__TAURI_INTERNALS__
@@ -83,19 +90,19 @@ function showBackendDownOverlay() {
overlay.innerHTML = `
<div class="login-card" style="text-align:center">
${_logoSvg}
<div class="login-title" style="color:var(--error,#ef4444)">后端未启动</div>
<div class="login-title" style="color:var(--error,#ef4444)">${t('common.backendDownTitle')}</div>
<div class="login-desc" style="line-height:1.8">
ClawPanel 后端服务未运行,无法获取真实数据。<br>
<span style="font-size:12px;color:var(--text-tertiary)">请在服务器上启动后端服务后刷新页面。</span>
${t('common.backendDownDesc')}<br>
<span style="font-size:12px;color:var(--text-tertiary)">${t('common.backendDownHint')}</span>
</div>
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md,8px);padding:14px 18px;margin:16px 0;text-align:left;font-family:var(--font-mono,monospace);font-size:12px;line-height:1.8;user-select:all;color:var(--text-secondary)">
<div style="color:var(--text-tertiary);margin-bottom:4px"># 开发模式</div>
<div style="color:var(--text-tertiary);margin-bottom:4px"># ${t('common.devMode')}</div>
npm run dev<br>
<div style="color:var(--text-tertiary);margin-top:8px;margin-bottom:4px"># 生产模式</div>
<div style="color:var(--text-tertiary);margin-top:8px;margin-bottom:4px"># ${t('common.prodMode')}</div>
npm run preview
</div>
<button class="login-btn" id="btn-backend-retry" style="margin-top:8px">
<span id="backend-retry-text">重新检测</span>
<span id="backend-retry-text">${t('common.checkAgain')}</span>
</button>
<div id="backend-retry-status" style="font-size:12px;color:var(--text-tertiary);margin-top:12px"></div>
<div style="margin-top:16px;font-size:11px;color:#aaa">
@@ -115,19 +122,19 @@ function showBackendDownOverlay() {
if (retrying) return
retrying = true
btn.disabled = true
textEl.textContent = '检测中...'
textEl.textContent = t('common.checking')
statusEl.textContent = ''
const ok = await checkBackendHealth()
if (ok) {
statusEl.textContent = '后端已连接,正在加载...'
statusEl.textContent = t('common.backendConnectedLoading')
statusEl.style.color = 'var(--success,#22c55e)'
overlay.classList.add('hide')
setTimeout(() => { overlay.remove(); location.reload() }, 600)
} else {
statusEl.textContent = '后端仍未响应,请确认服务已启动'
statusEl.textContent = t('common.backendStillDown')
statusEl.style.color = 'var(--error,#ef4444)'
textEl.textContent = '重新检测'
textEl.textContent = t('common.checkAgain')
btn.disabled = false
retrying = false
}
@@ -140,7 +147,7 @@ function showBackendDownOverlay() {
if (ok) {
clearInterval(_backendRetryTimer)
_backendRetryTimer = null
statusEl.textContent = '后端已连接,正在加载...'
statusEl.textContent = t('common.backendConnectedLoading')
statusEl.style.color = 'var(--success,#22c55e)'
overlay.classList.add('hide')
setTimeout(() => { overlay.remove(); location.reload() }, 600)
@@ -162,28 +169,31 @@ function showLoginOverlay(defaultPw) {
const overlay = document.createElement('div')
overlay.id = 'login-overlay'
let _captcha = _loginFailCount >= CAPTCHA_THRESHOLD ? _genCaptcha() : null
const securityLabel = t('sidebar.security')
const accessPasswordField = '<code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code>'
const resetPath = '<code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
overlay.innerHTML = `
<div class="login-card">
${_logoSvg}
<div class="login-title">ClawPanel</div>
<div class="login-desc">${hasDefault
? '首次使用,默认密码已自动填充<br><span style="font-size:12px;color:#6366f1;font-weight:600">登录后请前往「安全设置」修改密码</span>'
: (isTauri ? '应用已锁定,请输入密码' : '请输入访问密码')}</div>
? `${t('security.firstLoginHint')}<br><span style="font-size:12px;color:#6366f1;font-weight:600">${t('security.firstLoginChangeHint', { security: securityLabel })}</span>`
: (isTauri ? t('security.appLocked') : t('security.loginPrompt'))}</div>
<form id="login-form">
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="访问密码" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="${t('security.accessPasswordPlaceholder')}" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
<div id="login-captcha" style="display:${_captcha ? 'block' : 'none'};margin-bottom:10px">
<div style="font-size:12px;color:#888;margin-bottom:6px">请先完成验证:<strong id="captcha-q" style="color:var(--text-primary,#333)">${_captcha ? _captcha.q : ''}</strong></div>
<input class="login-input" type="number" id="login-captcha-input" placeholder="输入计算结果" style="text-align:center" />
<div style="font-size:12px;color:#888;margin-bottom:6px">${t('security.captchaPrompt')}<strong id="captcha-q" style="color:var(--text-primary,#333)">${_captcha ? _captcha.q : ''}</strong></div>
<input class="login-input" type="number" id="login-captcha-input" placeholder="${t('security.captchaPlaceholder')}" style="text-align:center" />
</div>
<button class="login-btn" type="submit">登 录</button>
<button class="login-btn" type="submit">${t('security.loginAction')}</button>
<div class="login-error" id="login-error"></div>
</form>
${!hasDefault ? `<details class="login-forgot" style="margin-top:16px;text-align:center">
<summary style="font-size:11px;color:#aaa;cursor:pointer;list-style:none;user-select:none">忘记密码?</summary>
<summary style="font-size:11px;color:#aaa;cursor:pointer;list-style:none;user-select:none">${t('security.forgotPassword')}</summary>
<div style="margin-top:8px;font-size:11px;color:#888;line-height:1.8;text-align:left;background:rgba(0,0,0,.03);border-radius:8px;padding:10px 14px">
${isTauri
? '删除配置文件中的 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段即可重置:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
: '编辑服务器上的配置文件,删除 <code style="background:rgba(99,102,241,.1);padding:1px 5px;border-radius:3px;font-size:10px">accessPassword</code> 字段后重启服务:<br><code style="background:rgba(99,102,241,.1);padding:2px 6px;border-radius:3px;font-size:10px;word-break:break-all">~/.openclaw/clawpanel.json</code>'
? `${t('security.resetPasswordLocal', { field: accessPasswordField })}<br>${resetPath}`
: `${t('security.resetPasswordRemote', { field: accessPasswordField })}<br>${resetPath}`
}
</div>
</details>` : ''}
@@ -203,19 +213,19 @@ function showLoginOverlay(defaultPw) {
const btn = overlay.querySelector('.login-btn')
const errEl = overlay.querySelector('#login-error')
btn.disabled = true
btn.textContent = '登录中...'
btn.textContent = t('security.loginSubmitting')
errEl.textContent = ''
// 验证码校验
if (_captcha) {
const captchaVal = parseInt(overlay.querySelector('#login-captcha-input')?.value)
if (captchaVal !== _captcha.a) {
errEl.textContent = '验证码错误'
errEl.textContent = t('security.wrongCaptcha')
_captcha = _genCaptcha()
const qEl = overlay.querySelector('#captcha-q')
if (qEl) qEl.textContent = _captcha.q
overlay.querySelector('#login-captcha-input').value = ''
btn.disabled = false
btn.textContent = '登 录'
btn.textContent = t('security.loginAction')
return
}
}
@@ -231,9 +241,9 @@ function showLoginOverlay(defaultPw) {
const cEl = overlay.querySelector('#login-captcha')
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
}
errEl.textContent = `密码错误${_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`}`
errEl.textContent = `${t('security.loginWrongPassword')}${_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`}`
btn.disabled = false
btn.textContent = '登 录'
btn.textContent = t('security.loginAction')
return
}
sessionStorage.setItem('clawpanel_authed', '1')
@@ -266,9 +276,9 @@ function showLoginOverlay(defaultPw) {
const cEl = overlay.querySelector('#login-captcha')
if (cEl) { cEl.style.display = 'block'; cEl.querySelector('#captcha-q').textContent = _captcha.q }
}
errEl.textContent = (data.error || '登录失败') + (_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`)
errEl.textContent = (data.error || t('security.loginFailed')) + (_loginFailCount >= CAPTCHA_THRESHOLD ? '' : ` (${_loginFailCount}/${CAPTCHA_THRESHOLD})`)
btn.disabled = false
btn.textContent = '登 录'
btn.textContent = t('security.loginAction')
return
}
overlay.classList.add('hide')
@@ -279,9 +289,9 @@ function showLoginOverlay(defaultPw) {
resolve()
}
} catch (err) {
errEl.textContent = '网络错误: ' + (err.message || err)
errEl.textContent = `${t('common.networkError')}: ${err.message || err}`
btn.disabled = false
btn.textContent = '登 录'
btn.textContent = t('security.loginAction')
}
})
})
@@ -306,6 +316,7 @@ async function boot() {
registerRoute('/logs', () => import('./pages/logs.js'))
registerRoute('/models', () => import('./pages/models.js'))
registerRoute('/agents', () => import('./pages/agents.js'))
registerRoute('/agent-detail', () => import('./pages/agent-detail.js'))
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/skills', () => import('./pages/skills.js'))
@@ -349,8 +360,8 @@ async function boot() {
banner.id = 'pw-change-banner'
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:13px;font-weight:500;box-shadow:0 2px 8px rgba(0,0,0,0.15)'
banner.innerHTML = `
<span>${statusIcon('warn', 14)} 当前使用的是系统生成的默认密码,为了安全请尽快修改</span>
<a href="#/security" style="color:#fff;background:rgba(255,255,255,0.2);padding:4px 14px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600" onclick="document.getElementById('pw-change-banner').remove();sessionStorage.removeItem('clawpanel_must_change_pw')">前往安全设置</a>
<span>${statusIcon('warn', 14)} ${t('common.defaultPasswordBanner')}</span>
<a href="#/security" style="color:#fff;background:rgba(255,255,255,0.2);padding:4px 14px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600" onclick="document.getElementById('pw-change-banner').remove();sessionStorage.removeItem('clawpanel_must_change_pw')">${t('common.goSecurity')}</a>
<button onclick="this.parentElement.remove()" style="background:none;border:none;color:rgba(255,255,255,0.7);cursor:pointer;font-size:16px;padding:0 4px;margin-left:4px">✕</button>
`
document.body.prepend(banner)
@@ -523,10 +534,10 @@ function setupGatewayBanner() {
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>Gateway 未运行</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">启动</button>
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
<button class="gw-banner-close" id="btn-gw-dismiss" title="关闭提示">&times;</button>
<span>${t('dashboard.controlUINotRunning')}</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">${t('dashboard.startBtn')}</button>
<a class="btn btn-sm btn-ghost" href="#/services">${t('sidebar.services')}</a>
<button class="gw-banner-close" id="btn-gw-dismiss" title="${t('common.close')}">&times;</button>
</div>
`
banner.querySelector('#btn-gw-dismiss')?.addEventListener('click', () => {
@@ -537,18 +548,23 @@ function setupGatewayBanner() {
const btn = e.target
btn.disabled = true
btn.classList.add('btn-loading')
btn.textContent = '启动中...'
btn.textContent = t('dashboard.starting')
try {
await api.startService('ai.openclaw.gateway')
} catch (err) {
if (isForeignGatewayError(err)) {
await openGatewayConflict(err)
update(false)
return
}
const errMsg = (err.message || String(err)).slice(0, 120)
banner.innerHTML = `
<div class="gw-banner-content" style="flex-wrap:wrap">
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>启动失败</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
<span>${t('dashboard.startFail')}</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">${t('dashboard.retry')}</button>
<a class="btn btn-sm btn-ghost" href="#/services">${t('sidebar.services')}</a>
<a class="btn btn-sm btn-ghost" href="#/logs">${t('sidebar.logs')}</a>
</div>
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${escapeHtml(errMsg)}</div>
`
@@ -564,7 +580,7 @@ function setupGatewayBanner() {
if (gw?.running) { update(true); return }
} catch {}
const sec = Math.floor((Date.now() - t0) / 1000)
btn.textContent = `启动中... ${sec}s`
btn.textContent = `${t('dashboard.starting')} ${sec}s`
await new Promise(r => setTimeout(r, 1500))
}
// 超时后尝试获取日志帮助排查
@@ -576,9 +592,9 @@ function setupGatewayBanner() {
banner.innerHTML = `
<div class="gw-banner-content">
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
<span>启动超时Gateway 可能仍在启动中</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
<span>${t('dashboard.startTimeout')}</span>
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">${t('dashboard.retry')}</button>
<a class="btn btn-sm btn-ghost" href="#/logs">${t('sidebar.logs')}</a>
</div>
${logHint}
`
@@ -643,7 +659,8 @@ function showGuardianRecovery() {
try {
await api.startService('ai.openclaw.gateway')
statusEl.innerHTML = `<span style="color:var(--success)">${t('dashboard.fixDoneRestarted')}</span>`
} catch {
} catch (err) {
if (isForeignGatewayError(err)) await openGatewayConflict(err)
statusEl.innerHTML = `<span style="color:var(--warning)">${t('dashboard.fixDoneRestartFail')}</span>`
}
}
@@ -664,6 +681,7 @@ function showGuardianRecovery() {
await api.startService('ai.openclaw.gateway')
btn.textContent = t('dashboard.startSent')
} catch (err) {
if (isForeignGatewayError(err)) await openGatewayConflict(err)
btn.textContent = t('dashboard.retryStart')
btn.disabled = false
}
@@ -701,16 +719,16 @@ async function checkGlobalUpdate() {
<div class="update-banner-content">
<div class="update-banner-text">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span class="update-banner-ver">ClawPanel v${ver} 可用</span>
<span class="update-banner-ver">${t('about.versionAvailable', { version: ver })}</span>
${changelog ? `<span class="update-banner-changelog">· ${changelog}</span>` : ''}
</div>
${isWeb
? `<button class="btn btn-sm" id="btn-update-show-cmd">更新方法</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">Release Notes</a>`
: `<button class="btn btn-sm" id="btn-update-hot">热更新</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">完整安装包</a>`
? `<button class="btn btn-sm" id="btn-update-show-cmd">${t('about.updateMethod')}</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">${t('about.releaseNotes')}</a>`
: `<button class="btn btn-sm" id="btn-update-hot">${t('about.hotUpdate')}</button>
<a class="btn btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener">${t('about.fullInstaller')}</a>`
}
<button class="update-banner-close" id="btn-update-dismiss" title="忽略此版本">✕</button>
<button class="update-banner-close" id="btn-update-dismiss" title="${t('about.dismissVersion')}">✕</button>
</div>
`
@@ -726,21 +744,20 @@ async function checkGlobalUpdate() {
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal" style="max-width:480px">
<div class="modal-title">更新到 v${ver}</div>
<div class="modal-title">${t('about.updateToVersion', { version: ver })}</div>
<div style="font-size:var(--font-size-sm);line-height:1.8">
<p style="margin-bottom:12px">在服务器上执行以下命令:</p>
<p style="margin-bottom:12px">${t('about.runOnServer')}</p>
<pre style="background:var(--bg-tertiary);padding:12px 16px;border-radius:var(--radius-md);font-family:var(--font-mono);font-size:var(--font-size-xs);overflow-x:auto;white-space:pre-wrap;user-select:all">cd /opt/clawpanel
git pull origin main
npm install
npm run build
sudo systemctl restart clawpanel</pre>
<p style="margin-top:12px;color:var(--text-tertiary);font-size:var(--font-size-xs)">
如果 git pull 失败,可先执行 <code style="background:var(--bg-tertiary);padding:2px 6px;border-radius:4px">git checkout -- .</code> 丢弃本地修改。<br>
路径请替换为实际的 ClawPanel 安装目录。
${t('about.updateCommandHint')}
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
<button class="btn btn-secondary btn-sm" data-action="close">${t('common.close')}</button>
</div>
</div>
`
@@ -755,18 +772,18 @@ sudo systemctl restart clawpanel</pre>
const btn = banner.querySelector('#btn-update-hot')
if (!btn) return
btn.disabled = true
btn.textContent = '下载中...'
btn.textContent = t('about.downloading')
try {
await api.downloadFrontendUpdate(info.manifest?.url || '', info.manifest?.hash || '')
localStorage.setItem('clawpanel_hot_update_applied', ver)
btn.textContent = '重载应用'
btn.textContent = t('about.reloadApp')
btn.disabled = false
btn.onclick = () => window.location.reload()
} catch (e) {
btn.textContent = '下载失败'
btn.textContent = t('about.downloadFailedShort')
btn.disabled = false
const { toast } = await import('./components/toast.js')
toast('更新下载失败: ' + (e.message || e), 'error')
toast(t('about.downloadFailed') + (e.message || e), 'error')
}
})
} catch {
@@ -803,10 +820,10 @@ function startUpdateChecker() {
if (app) app.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:20px;text-align:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<div style="font-size:48px;margin-bottom:16px">⚠️</div>
<div style="font-size:18px;font-weight:600;margin-bottom:8px;color:#18181b">页面加载失败</div>
<div style="font-size:18px;font-weight:600;margin-bottom:8px;color:#18181b">${t('common.pageLoadFailed')}</div>
<div style="font-size:13px;color:#71717a;max-width:400px;line-height:1.6;margin-bottom:16px">${String(bootErr?.message || bootErr).replace(/</g,'&lt;')}</div>
<button onclick="location.reload()" style="padding:8px 20px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-size:13px;cursor:pointer">刷新重试</button>
<div style="margin-top:24px;font-size:11px;color:#a1a1aa">如果问题持续出现,请尝试重新安装 ClawPanel<br>或在 <a href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" style="color:#6366f1">GitHub Issues</a> 反馈</div>
<button onclick="location.reload()" style="padding:8px 20px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-size:13px;cursor:pointer">${t('common.reloadRetry')}</button>
<div style="margin-top:24px;font-size:11px;color:#a1a1aa">${t('common.pageLoadFailedHint')}<br><a href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" style="color:#6366f1">GitHub Issues</a></div>
</div>`
}
startUpdateChecker()

697
src/pages/agent-detail.js Normal file
View File

@@ -0,0 +1,697 @@
/**
* Agent 详情页
* 概览 / 文件 / 渠道 三个 Tab
*/
import { api, invalidate } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
function esc(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function render() {
const params = new URLSearchParams(location.hash.split('?')[1] || '')
const agentId = params.get('id') || 'main'
const page = document.createElement('div')
page.className = 'page agent-detail-page'
page.innerHTML = `
<div class="page-header">
<div>
<a class="agent-back-link" href="#/agents">${t('agentDetail.back')}</a>
<h1 class="page-title" id="agent-detail-title">Agent: ${esc(agentId)}</h1>
</div>
</div>
<div class="tab-bar" id="agent-tabs">
<div class="tab active" data-tab="overview">${t('agentDetail.tabOverview')}</div>
<div class="tab" data-tab="files">${t('agentDetail.tabFiles')}</div>
<div class="tab" data-tab="channels">${t('agentDetail.tabChannels')}</div>
<div class="tab" data-tab="tools">${t('agentDetail.tabTools')}</div>
<div class="tab" data-tab="skills">${t('agentDetail.tabSkills')}</div>
</div>
<div class="page-content">
<div id="agent-tab-content"></div>
</div>
`
const state = { agentId, detail: null, files: null, models: [], skillsCatalog: [] }
// Tab 切换
page.querySelector('#agent-tabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab')
if (!tab) return
page.querySelectorAll('#agent-tabs .tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
switchTab(page, state, tab.dataset.tab)
})
// 首次加载
loadDetail(page, state)
return page
}
async function loadDetail(page, state) {
const content = page.querySelector('#agent-tab-content')
content.innerHTML = '<div class="skeleton" style="width:100%;height:200px;border-radius:8px"></div>'
try {
const [detail, config, skillsResp] = await Promise.all([
api.getAgentDetail(state.agentId),
api.readOpenclawConfig().catch(() => null),
api.skillsList().catch(() => ({ skills: [] })),
])
state.detail = detail
// 解析可用模型
state.models = parseModelList(config)
state.skillsCatalog = Array.isArray(skillsResp?.skills) ? skillsResp.skills : []
// 更新标题
const title = page.querySelector('#agent-detail-title')
const name = detail.identity?.name || detail.name || detail.id
const emoji = detail.identity?.emoji || ''
title.textContent = `${emoji} ${name}`.trim()
if (detail.isDefault) {
title.insertAdjacentHTML('beforeend', ` <span class="badge badge-success">${t('agentDetail.defaultAgent')}</span>`)
}
switchTab(page, state, 'overview')
} catch (e) {
content.innerHTML = `<div style="color:var(--error);padding:20px">${t('agentDetail.loadFailed')}: ${esc(String(e))}</div>`
}
}
function parseModelList(config) {
const models = []
const providers = config?.models?.providers || {}
for (const [pk, pv] of Object.entries(providers)) {
for (const m of (pv.models || [])) {
const id = typeof m === 'string' ? m : m.id
if (id) models.push(`${pk}/${id}`)
}
}
return models
}
function switchTab(page, state, tab) {
const content = page.querySelector('#agent-tab-content')
if (tab === 'overview') renderOverview(content, state)
else if (tab === 'files') renderFiles(content, state)
else if (tab === 'channels') renderChannels(content, state)
else if (tab === 'tools') renderTools(content, state)
else if (tab === 'skills') renderSkills(content, state)
}
// ==================== 概览 Tab ====================
function renderOverview(container, state) {
const d = state.detail
if (!d) { container.innerHTML = ''; return }
// 解析模型配置
let primaryModel = ''
let fallbacks = []
if (d.model) {
if (typeof d.model === 'string') {
primaryModel = d.model
} else if (typeof d.model === 'object') {
primaryModel = d.model.primary || ''
fallbacks = Array.isArray(d.model.fallbacks) ? [...d.model.fallbacks] : []
}
}
const thinkingLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive']
container.innerHTML = `
<div class="agent-overview">
<section class="agent-section">
<h3 class="agent-section-title">${t('agentDetail.basicInfo')}</h3>
<div class="agent-form-grid">
<div class="form-group">
<label class="form-label">${t('agentDetail.agentId')}</label>
<input class="form-input" value="${esc(d.id)}" readonly style="opacity:0.6;cursor:not-allowed">
</div>
<div class="form-group">
<label class="form-label">${t('agentDetail.name')}</label>
<input class="form-input" id="ov-name" value="${esc(d.identity?.name || d.name || '')}" placeholder="${t('agentDetail.notSet')}">
</div>
<div class="form-group">
<label class="form-label">${t('agentDetail.emoji')}</label>
<input class="form-input" id="ov-emoji" value="${esc(d.identity?.emoji || '')}" placeholder="🤖" style="max-width:80px">
</div>
<div class="form-group">
<label class="form-label">${t('agentDetail.workspace')}</label>
<input class="form-input" value="${esc(d.workspace || t('agentDetail.notSet'))}" readonly style="opacity:0.6;cursor:not-allowed;font-family:var(--font-mono);font-size:var(--font-size-xs)">
</div>
</div>
</section>
<section class="agent-section">
<h3 class="agent-section-title">${t('agentDetail.modelConfig')}</h3>
<div class="agent-form-grid">
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">${t('agentDetail.primaryModel')}</label>
${renderModelSelect('ov-primary-model', primaryModel, state.models)}
</div>
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">${t('agentDetail.fallbackModels')}</label>
<div id="ov-fallbacks">${renderFallbackList(fallbacks, state.models)}</div>
<button class="btn btn-sm btn-secondary" id="btn-add-fallback" style="margin-top:8px">${t('agentDetail.addFallback')}</button>
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">${t('agentDetail.thinkingLevel')}</label>
<select class="form-input" id="ov-thinking" style="max-width:200px">
<option value="">${t('agentDetail.notSet')}</option>
${thinkingLevels.map(lv => `<option value="${lv}" ${d.thinkingDefault === lv ? 'selected' : ''}>${t('agentDetail.thinking' + lv.charAt(0).toUpperCase() + lv.slice(1))}</option>`).join('')}
</select>
</div>
</section>
<div class="agent-save-bar">
<button class="btn btn-primary" id="btn-save-overview">${t('agentDetail.saveOverview')}</button>
</div>
</div>
`
// 添加备选模型
container.querySelector('#btn-add-fallback').addEventListener('click', () => {
const list = container.querySelector('#ov-fallbacks')
const idx = list.querySelectorAll('.fallback-row').length
list.insertAdjacentHTML('beforeend', renderFallbackRow('', state.models, idx))
})
// 移除备选模型(事件代理)
container.querySelector('#ov-fallbacks').addEventListener('click', (e) => {
if (e.target.closest('.btn-remove-fallback')) {
e.target.closest('.fallback-row').remove()
}
})
// 保存
container.querySelector('#btn-save-overview').addEventListener('click', () => saveOverview(container, state))
}
function renderModelSelect(id, selected, models) {
if (!models.length) {
return `<input class="form-input" id="${id}" value="${esc(selected)}" placeholder="provider/model">`
}
// 如果当前值不在列表中,添加到选项
const opts = [...models]
if (selected && !opts.includes(selected)) opts.unshift(selected)
return `
<select class="form-input" id="${id}">
<option value="">${t('agentDetail.notSet')}</option>
${opts.map(m => `<option value="${esc(m)}" ${m === selected ? 'selected' : ''}>${esc(m)}</option>`).join('')}
</select>
`
}
function renderFallbackList(fallbacks, models) {
if (!fallbacks.length) {
return `<div class="agent-hint">${t('agentDetail.noFallback')}</div>`
}
return fallbacks.map((fb, i) => renderFallbackRow(fb, models, i)).join('')
}
function renderFallbackRow(value, models, idx) {
const opts = [...models]
if (value && !opts.includes(value)) opts.unshift(value)
return `
<div class="fallback-row" style="display:flex;gap:8px;align-items:center;margin-top:6px">
<select class="form-input fallback-select" style="flex:1">
<option value="">${t('agentDetail.notSet')}</option>
${opts.map(m => `<option value="${esc(m)}" ${m === value ? 'selected' : ''}>${esc(m)}</option>`).join('')}
</select>
<button class="btn btn-sm btn-danger btn-remove-fallback">${t('agentDetail.removeFallback')}</button>
</div>
`
}
async function saveOverview(container, state) {
const btn = container.querySelector('#btn-save-overview')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const name = container.querySelector('#ov-name')?.value?.trim() || ''
const emoji = container.querySelector('#ov-emoji')?.value?.trim() || ''
const primaryEl = container.querySelector('#ov-primary-model')
const primary = primaryEl?.value?.trim() || ''
const thinkingDefault = container.querySelector('#ov-thinking')?.value || ''
// 收集备选模型
const fallbacks = []
container.querySelectorAll('.fallback-select').forEach(sel => {
const v = sel.value.trim()
if (v) fallbacks.push(v)
})
// 构建模型配置
let model = primary || undefined
if (primary && fallbacks.length > 0) {
model = { primary, fallbacks }
}
await api.updateAgentConfig(state.agentId, {
identity: { name: name || undefined, emoji: emoji || undefined },
model,
thinkingDefault: thinkingDefault || undefined,
})
// 更新本地缓存
invalidate('list_agents', 'get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.saveSuccess'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveOverview')
}
}
// ==================== 工具 Tab ====================
function renderTools(container, state) {
const tools = state.detail?.tools || {}
const profile = tools.profile || ''
const allow = Array.isArray(tools.allow) ? tools.allow.join(', ') : ''
const alsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow.join(', ') : ''
const deny = Array.isArray(tools.deny) ? tools.deny.join(', ') : ''
container.innerHTML = `
<div class="agent-overview">
<section class="agent-section">
<h3 class="agent-section-title">${t('agentDetail.toolsTitle')}</h3>
<p class="agent-section-desc">${t('agentDetail.toolsDesc')}</p>
<div class="agent-form-grid">
<div class="form-group">
<label class="form-label">${t('agentDetail.toolProfile')}</label>
<select class="form-input" id="tools-profile">
<option value="">${t('agentDetail.notSet')}</option>
<option value="minimal" ${profile === 'minimal' ? 'selected' : ''}>minimal</option>
<option value="coding" ${profile === 'coding' ? 'selected' : ''}>coding</option>
<option value="messaging" ${profile === 'messaging' ? 'selected' : ''}>messaging</option>
<option value="full" ${profile === 'full' ? 'selected' : ''}>full</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">${t('agentDetail.toolAllow')}</label>
<textarea class="form-input agent-multiline-input" id="tools-allow" placeholder="read_file, write_file, exec">${esc(allow)}</textarea>
<div class="form-hint">${t('agentDetail.toolAllowHint')}</div>
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">${t('agentDetail.toolAlsoAllow')}</label>
<textarea class="form-input agent-multiline-input" id="tools-also-allow" placeholder="grep_search, apply_patch">${esc(alsoAllow)}</textarea>
<div class="form-hint">${t('agentDetail.toolAlsoAllowHint')}</div>
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">${t('agentDetail.toolDeny')}</label>
<textarea class="form-input agent-multiline-input" id="tools-deny" placeholder="delete_file">${esc(deny)}</textarea>
<div class="form-hint">${t('agentDetail.toolDenyHint')}</div>
</div>
</section>
<div class="agent-save-bar">
<button class="btn btn-primary" id="btn-save-tools">${t('agentDetail.saveTools')}</button>
</div>
</div>
`
container.querySelector('#btn-save-tools').addEventListener('click', () => saveTools(container, state))
}
async function saveTools(container, state) {
const btn = container.querySelector('#btn-save-tools')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const tools = {
profile: container.querySelector('#tools-profile')?.value || undefined,
allow: splitCsv(container.querySelector('#tools-allow')?.value),
alsoAllow: splitCsv(container.querySelector('#tools-also-allow')?.value),
deny: splitCsv(container.querySelector('#tools-deny')?.value),
}
await api.updateAgentConfig(state.agentId, { tools: compactObject(tools) })
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.toolsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveTools')
}
}
// ==================== 技能 Tab ====================
function renderSkills(container, state) {
const selected = new Set(Array.isArray(state.detail?.skills) ? state.detail.skills : [])
const skills = state.skillsCatalog || []
container.innerHTML = `
<div class="agent-overview">
<section class="agent-section">
<h3 class="agent-section-title">${t('agentDetail.skillsTitle')}</h3>
<p class="agent-section-desc">${t('agentDetail.skillsDesc')}</p>
<div class="agent-skills-list">
${skills.length ? skills.map(skill => renderSkillCard(skill, selected.has(skill.name))).join('') : `<div class="agent-hint">${t('agentDetail.noSkills')}</div>`}
</div>
</section>
<div class="agent-save-bar">
<button class="btn btn-primary" id="btn-save-skills">${t('agentDetail.saveSkills')}</button>
</div>
</div>
`
container.querySelector('#btn-save-skills').addEventListener('click', () => saveSkills(container, state))
}
function renderSkillCard(skill, checked) {
const emoji = skill.emoji || '🧩'
const desc = skill.description || ''
const eligible = skill.eligible !== false
const disabled = skill.disabled === true
return `
<label class="agent-skill-card ${!eligible || disabled ? 'is-muted' : ''}">
<input type="checkbox" class="agent-skill-checkbox" data-skill-name="${esc(skill.name)}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<div class="agent-skill-main">
<div class="agent-skill-head">
<span class="agent-skill-name">${emoji} ${esc(skill.name)}</span>
${disabled ? `<span class="agent-skill-badge">${t('agentDetail.skillDisabled')}</span>` : ''}
${!eligible && !disabled ? `<span class="agent-skill-badge">${t('agentDetail.skillUnavailable')}</span>` : ''}
</div>
<div class="agent-skill-desc">${esc(desc)}</div>
</div>
</label>
`
}
async function saveSkills(container, state) {
const btn = container.querySelector('#btn-save-skills')
btn.disabled = true
btn.textContent = t('agentDetail.saving')
try {
const selected = []
container.querySelectorAll('.agent-skill-checkbox:checked').forEach((el) => selected.push(el.dataset.skillName))
await api.updateAgentConfig(state.agentId, { skills: selected })
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
toast(t('agentDetail.skillsSaved'), 'success')
} catch (e) {
toast(t('agentDetail.saveFailed') + ': ' + e, 'error')
} finally {
btn.disabled = false
btn.textContent = t('agentDetail.saveSkills')
}
}
function splitCsv(raw) {
if (!raw) return undefined
const values = String(raw)
.split(/[\n,]/)
.map(item => item.trim())
.filter(Boolean)
return values.length ? values : undefined
}
function compactObject(obj) {
const next = {}
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null && value !== '') next[key] = value
}
return Object.keys(next).length ? next : undefined
}
// ==================== 文件 Tab ====================
async function renderFiles(container, state) {
container.innerHTML = `
<div class="agent-files-section">
<h3 class="agent-section-title">${t('agentDetail.filesTitle')}</h3>
<p class="agent-section-desc">${t('agentDetail.filesDesc')}</p>
<div id="agent-files-list"><div class="skeleton" style="width:100%;height:120px;border-radius:8px"></div></div>
</div>
`
try {
const files = await api.listAgentFiles(state.agentId)
state.files = files
renderFileList(container, state)
} catch (e) {
container.querySelector('#agent-files-list').innerHTML =
`<div style="color:var(--error)">${t('agentDetail.loadFailed')}: ${esc(String(e))}</div>`
}
}
function renderFileList(container, state) {
const list = container.querySelector('#agent-files-list')
const files = state.files || []
if (!files.length) {
list.innerHTML = `<div style="color:var(--text-tertiary)">${t('agentDetail.noFiles')}</div>`
return
}
list.innerHTML = files.map(f => {
const statusClass = f.exists ? 'file-exists' : 'file-missing'
const statusText = f.exists ? t('agentDetail.fileExists') : t('agentDetail.fileMissing')
const sizeText = f.exists ? formatSize(f.size) : '-'
const timeText = f.exists && f.mtime ? new Date(f.mtime).toLocaleString('zh-CN') : '-'
const actionBtn = f.exists
? `<button class="btn btn-sm btn-secondary" data-action="edit-file" data-name="${esc(f.name)}">${t('agentDetail.fileEdit')}</button>`
: `<button class="btn btn-sm btn-primary" data-action="create-file" data-name="${esc(f.name)}">${t('agentDetail.fileCreate')}</button>`
return `
<div class="agent-file-card">
<div class="agent-file-header">
<div class="agent-file-info">
<span class="agent-file-name">${esc(f.name)}</span>
<span class="agent-file-status ${statusClass}">${statusText}</span>
</div>
<div class="agent-file-actions">${actionBtn}</div>
</div>
<div class="agent-file-desc">${esc(f.desc)}</div>
${f.exists ? `<div class="agent-file-meta">${t('agentDetail.fileSize')}: ${sizeText} · ${t('agentDetail.fileUpdated')}: ${timeText}</div>` : ''}
</div>
`
}).join('')
// 事件代理
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const name = btn.dataset.name
if (btn.dataset.action === 'edit-file') openFileEditor(container, state, name)
else if (btn.dataset.action === 'create-file') openFileEditor(container, state, name, true)
})
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
async function openFileEditor(container, state, name, isNew = false) {
let content = ''
if (!isNew) {
try {
const res = await api.readAgentFile(state.agentId, name)
content = res.content || ''
} catch (e) {
toast(t('agentDetail.loadFailed') + ': ' + e, 'error')
return
}
}
// 用弹窗编辑器
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
overlay.innerHTML = `
<div class="modal agent-file-editor-modal">
<div class="modal-title">${t('agentDetail.editFileTitle', { name })}</div>
<textarea class="agent-file-editor" id="file-editor-textarea" spellcheck="false">${esc(content)}</textarea>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
<button class="btn btn-primary btn-sm" data-action="save">${t('agentDetail.saveOverview')}</button>
</div>
</div>
`
document.body.appendChild(overlay)
const textarea = overlay.querySelector('#file-editor-textarea')
textarea.focus()
// Tab 键支持
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault()
const start = textarea.selectionStart
const end = textarea.selectionEnd
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end)
textarea.selectionStart = textarea.selectionEnd = start + 2
}
})
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove()
})
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
overlay.querySelector('[data-action="save"]').onclick = async () => {
try {
await api.writeAgentFile(state.agentId, name, textarea.value)
toast(isNew ? t('agentDetail.fileCreated') : t('agentDetail.fileSaved'), 'success')
overlay.remove()
// 刷新文件列表
renderFiles(container, state)
} catch (e) {
toast(t('agentDetail.fileSaveFailed') + ': ' + e, 'error')
}
}
// Ctrl+S 快捷保存
overlay.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
overlay.querySelector('[data-action="save"]').click()
}
if (e.key === 'Escape') overlay.remove()
})
}
// ==================== 渠道 Tab ====================
async function renderChannels(container, state) {
const bindings = state.detail?.bindings || []
// 获取已配置的渠道
let platforms = []
try { platforms = await api.listConfiguredPlatforms() } catch {}
container.innerHTML = `
<div class="agent-channels-section">
<div class="agent-section-header">
<div>
<h3 class="agent-section-title">${t('agentDetail.channelsTitle')}</h3>
<p class="agent-section-desc">${t('agentDetail.channelsDesc')}</p>
</div>
<button class="btn btn-sm btn-primary" id="btn-add-binding">${t('agentDetail.addBinding')}</button>
</div>
<div id="agent-bindings-list"></div>
</div>
`
renderBindingsList(container, state, bindings)
container.querySelector('#btn-add-binding').addEventListener('click', () => {
showAddBindingDialog(container, state, platforms)
})
}
function renderBindingsList(container, state, bindings) {
const list = container.querySelector('#agent-bindings-list')
if (!bindings.length) {
list.innerHTML = `<div class="agent-hint">${t('agentDetail.noBindings')}</div>`
return
}
list.innerHTML = bindings.map((b, i) => {
const channel = b.match?.channel || ''
const label = CHANNEL_LABELS[channel] || channel
const accountId = b.match?.accountId || ''
const typeLabel = b.type === 'acp' ? 'ACP' : 'Route'
return `
<div class="agent-binding-card">
<div class="agent-binding-info">
<span class="agent-binding-channel">${esc(label)}</span>
${accountId ? `<span class="agent-binding-account">${esc(accountId)}</span>` : ''}
<span class="badge" style="background:var(--info-muted);color:var(--info)">${typeLabel}</span>
</div>
<button class="btn btn-sm btn-danger" data-action="remove-binding" data-channel="${esc(channel)}" data-account="${esc(accountId)}" data-index="${i}">${t('agentDetail.removeBinding')}</button>
</div>
`
}).join('')
list.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="remove-binding"]')
if (!btn) return
const channel = btn.dataset.channel
const account = btn.dataset.account || null
const binding = bindings[Number(btn.dataset.index)]
const yes = await showConfirm(t('agentDetail.removeBindingConfirm', { channel: CHANNEL_LABELS[channel] || channel }))
if (!yes) return
try {
await api.deleteAgentBinding(state.agentId, channel, account, binding?.match || null)
toast(t('agentDetail.bindingRemoved'), 'success')
// 刷新
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
renderBindingsList(container, state, state.detail.bindings || [])
} catch (e) {
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
}
})
}
function showAddBindingDialog(container, state, platforms) {
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
// 构建渠道选项:已配置的渠道 + 所有已知渠道
const channels = new Set()
for (const p of platforms) {
if (p.platform || p.id) channels.add(p.platform || p.id)
}
// 确保常用渠道在列表中
for (const key of Object.keys(CHANNEL_LABELS)) channels.add(key)
const channelOptions = [...channels].map(ch =>
`<option value="${esc(ch)}">${esc(CHANNEL_LABELS[ch] || ch)}</option>`
).join('')
overlay.innerHTML = `
<div class="modal" style="max-width:400px">
<div class="modal-title">${t('agentDetail.addBinding')}</div>
<div class="form-group">
<label class="form-label">${t('agentDetail.selectChannel')}</label>
<select class="form-input" id="bind-channel">${channelOptions}</select>
</div>
<div class="form-group">
<label class="form-label">${t('agentDetail.accountOptional')}</label>
<input class="form-input" id="bind-account" placeholder="${t('agentDetail.accountOptionalPlaceholder')}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
</div>
</div>
`
document.body.appendChild(overlay)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
overlay.querySelector('[data-action="confirm"]').onclick = async () => {
const channel = overlay.querySelector('#bind-channel').value
const account = overlay.querySelector('#bind-account').value.trim() || null
if (!channel) return
try {
await api.saveAgentBinding(state.agentId, channel, account)
toast(t('agentDetail.bindingAdded'), 'success')
overlay.remove()
invalidate('get_agent_detail')
state.detail = await api.getAgentDetail(state.agentId)
renderBindingsList(container, state, state.detail.bindings || [])
} catch (e) {
toast(t('agentDetail.bindingFailed') + ': ' + e, 'error')
overlay.remove()
}
}
overlay.querySelector('[data-action="confirm"]').addEventListener('keydown', (e) => {
if (e.key === 'Enter') overlay.querySelector('[data-action="confirm"]').click()
if (e.key === 'Escape') overlay.remove()
})
}

View File

@@ -17,6 +17,7 @@ export async function render() {
<div>
<h1 class="page-title">${t('agents.title')}</h1>
<p class="page-desc">${t('agents.desc')}</p>
<p class="page-subhint">${t('agents.detailHint')}</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="btn-add-agent">${t('agents.addAgent')}</button>
@@ -107,6 +108,7 @@ function renderAgents(page, state) {
${isDefault ? `<span class="badge badge-success">${t('agents.default')}</span>` : ''}
</div>
<div class="agent-card-actions">
<button class="btn btn-sm btn-primary" data-action="detail" data-id="${a.id}">${t('agents.detail')}</button>
<button class="btn btn-sm btn-secondary" data-action="backup" data-id="${a.id}">${t('agents.backup')}</button>
<button class="btn btn-sm btn-secondary" data-action="edit" data-id="${a.id}">${t('agents.edit')}</button>
${!isDefault ? `<button class="btn btn-sm btn-danger" data-action="delete" data-id="${a.id}">${t('agents.delete')}</button>` : ''}
@@ -139,13 +141,21 @@ function attachAgentEvents(page, state) {
const container = page.querySelector('#agents-list')
container.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
const id = btn.dataset.id
if (action === 'edit') showEditAgentDialog(page, state, id)
else if (action === 'delete') await deleteAgent(page, state, id)
else if (action === 'backup') await backupAgent(id)
if (btn) {
const action = btn.dataset.action
const id = btn.dataset.id
if (action === 'detail') location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
else if (action === 'edit') showEditAgentDialog(page, state, id)
else if (action === 'delete') await deleteAgent(page, state, id)
else if (action === 'backup') await backupAgent(id)
return
}
// 点击卡片空白区域 → 进入详情页
const card = e.target.closest('.agent-card')
if (card) {
const id = card.dataset.id
if (id) location.hash = `#/agent-detail?id=${encodeURIComponent(id)}`
}
})
}

View File

@@ -996,7 +996,7 @@ async function loadOpenClawSoul(agentId = 'default') {
try {
const sysInfo = await api.assistantSystemInfo()
const home = sysInfo.match(/主目录[:]\s*(.+)/)?.[1]?.trim() || sysInfo.match(/Home[:]\s*(.+)/)?.[1]?.trim() || ''
if (!home) throw new Error('无法获取主目录')
if (!home) throw new Error(t('assistant.errHomeUnavailable'))
// default/main 使用 ~/.openclaw/workspace其他使用 agents/{id}/workspace
let ws
if (agentId === 'default' || agentId === 'main') {
@@ -1006,7 +1006,7 @@ async function loadOpenClawSoul(agentId = 'default') {
}
let wsExists = false
try { await api.assistantListDir(ws); wsExists = true } catch {}
if (!wsExists) throw new Error('Agent workspace 不存在: ' + agentId)
if (!wsExists) throw new Error(t('assistant.errWorkspaceMissing', { agentId }))
const readSafe = async (p) => { try { return await api.assistantReadFile(p) } catch { return null } }
@@ -1265,7 +1265,7 @@ function renderImagePreview() {
container.innerHTML = _pendingImages.map(img => `
<div class="ast-img-thumb" data-img-id="${img.id}">
<img src="${img.dataUrl}" alt="${escHtml(img.name)}"/>
<button class="ast-img-thumb-del" data-img-del="${img.id}" title="移除">${delSvg}</button>
<button class="ast-img-thumb-del" data-img-del="${img.id}" title="${t('common.delete')}">${delSvg}</button>
</div>
`).join('')
}
@@ -2217,7 +2217,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
const choice = data.choices?.[0]
const assistantMsg = choice?.message
if (!assistantMsg) throw new Error('AI 未返回有效响应')
if (!assistantMsg) throw new Error(t('assistant.errInvalidResponse'))
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
currentMessages.push(assistantMsg)

View File

@@ -838,7 +838,7 @@ function renderAgentBindings(page, state) {
const yes = await showConfirm(t('channels.confirmRemoveBinding', { agent: aid, summary: formatBindingMatchSummary(binding) }))
if (!yes) return
try {
await api.deleteAgentBinding(aid, ch, acct)
await api.deleteAgentBinding(aid, ch, acct, match)
toast(t('channels.bindingRemoved'), 'success')
await loadPlatforms(page, state)
} catch (e) {
@@ -1326,11 +1326,11 @@ async function openConfigDialog(pid, page, state, accountId) {
const parts = []
const installBtn = modal.querySelector('[data-channel-action="install"]')
if (s.installed && s.compatible === false) {
parts.push(`<span style="color:var(--error);font-weight:600">⚠ ${t('channels.pluginIncompatible') || '插件版本不兼容'}</span>`)
parts.push(`<span style="color:var(--error);font-weight:600">⚠ ${t('channels.pluginIncompatible')}</span>`)
parts.push(`${t('channels.version')} <strong>${s.installedVersion || '?'}</strong>`)
parts.push(`<br><span style="color:var(--error);font-size:var(--font-size-xs)">${s.compatError || '请点击「一键安装插件」重新安装兼容版本'}</span>`)
parts.push(`<br><span style="color:var(--error);font-size:var(--font-size-xs)">${s.compatError || t('channels.pluginCompatErrorHint')}</span>`)
if (installBtn) {
installBtn.textContent = t('channels.reinstallCompatible') || '重新安装兼容版本'
installBtn.textContent = t('channels.reinstallCompatible')
installBtn.style.background = 'var(--error)'
}
} else if (s.installed) {
@@ -1386,7 +1386,7 @@ async function openConfigDialog(pid, page, state, accountId) {
const hint = document.createElement('div')
hint.style.cssText = 'color:var(--text-tertiary);font-style:italic'
hint.id = 'action-loading-hint'
hint.textContent = t('channels.downloadingPlugin') || '正在下载,请稍候(首次安装可能需要几分钟)...'
hint.textContent = t('channels.downloadingPlugin')
logBox.appendChild(hint)
}
const _qrBuf = []
@@ -1451,7 +1451,7 @@ async function openConfigDialog(pid, page, state, accountId) {
wrap.innerHTML = `
<div style="font-size:var(--font-size-sm);font-weight:600;color:#000;margin-bottom:8px">${t('channels.weixinScanQr')}</div>
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}" alt="WeChat QR" style="width:200px;height:200px;image-rendering:pixelated;border-radius:4px;margin:0 auto;display:block" loading="eager">
<div style="margin-top:8px"><a href="${escapeAttr(qrUrl)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:var(--font-size-xs);word-break:break-all">${t('channels.weixinOpenInBrowser') || '或点击此链接在浏览器中打开'}</a></div>
<div style="margin-top:8px"><a href="${escapeAttr(qrUrl)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:var(--font-size-xs);word-break:break-all">${t('channels.weixinOpenInBrowser')}</a></div>
`
logBox.appendChild(wrap)
} else if (msg.trim()) {

View File

@@ -5,6 +5,7 @@
import { api, getRequestLogs, clearRequestLogs } from '../lib/tauri-api.js'
import { wsClient } from '../lib/ws-client.js'
import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js'
import { isForeignGatewayError, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { icon, statusIcon } from '../lib/icons.js'
import { toast } from '../components/toast.js'
import { navigate } from '../router.js'
@@ -76,6 +77,12 @@ export async function render() {
return page
}
async function openGatewayConflict(error = null) {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
await showGatewayConflictGuidance({ error, service: gw })
}
async function loadDebugInfo(page) {
const el = page.querySelector('#debug-content')
@@ -589,13 +596,27 @@ async function fixPairing(page) {
// 2. 停止 Gateway确保旧进程完全退出新进程能重新读取配置
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStoppingGw')}`)
try { await api.stopService('ai.openclaw.gateway') } catch {}
try {
await api.stopService('ai.openclaw.gateway')
} catch (e) {
if (isForeignGatewayError(e)) {
await openGatewayConflict(e)
throw e
}
}
addLog(`${icon('clock', 14)} ${t('chatDebug.fixWaitExit')}`)
await new Promise(resolve => setTimeout(resolve, 3000))
// 3. 启动 Gateway重新加载 openclaw.json 配置)
addLog(`${icon('zap', 14)} ${t('chatDebug.fixStartingGw')}`)
await api.startService('ai.openclaw.gateway')
try {
await api.startService('ai.openclaw.gateway')
} catch (e) {
if (isForeignGatewayError(e)) {
await openGatewayConflict(e)
}
throw e
}
addLog(`${statusIcon('ok', 14)} ${t('chatDebug.fixGwStartSent')}`)
// 4. 等待 Gateway 就绪

View File

@@ -4,6 +4,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { getActiveInstance, onGatewayChange } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
@@ -64,6 +65,31 @@ export function cleanup() {
if (_unsubGw) { _unsubGw(); _unsubGw = null }
}
function openclawInstallationIdentity(installation) {
const rawPath = String(installation?.path || '').trim()
if (!rawPath) return ''
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
if (!isWin) return rawPath
return rawPath
.replace(/\//g, '\\')
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
.toLowerCase()
}
function dedupeOpenclawInstallations(list = []) {
const map = new Map()
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
for (const installation of Array.isArray(list) ? list : []) {
const key = openclawInstallationIdentity(installation)
if (!key) continue
const existing = map.get(key)
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
map.set(key, installation)
}
}
return [...map.values()]
}
let _dashboardInitialized = false
let _dashboardVersionCache = null
let _dashboardStatusSummaryCache = null
@@ -97,9 +123,7 @@ async function loadDashboardData(page, fullRefresh = false) {
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
// getStatusSummary 是最重的调用spawn openclaw status --json只在首次加载时调用
(!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache) ? api.getStatusSummary() : Promise.resolve(_dashboardStatusSummaryCache),
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
@@ -109,6 +133,11 @@ async function loadDashboardData(page, fullRefresh = false) {
? (_dashboardVersionCache = versionRes.value)
: (_dashboardVersionCache || {})
const config = configRes.status === 'fulfilled' ? configRes.value : null
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const shouldLoadStatusSummary = gw?.running === true
if (!shouldLoadStatusSummary) {
_dashboardStatusSummaryCache = null
}
if (servicesRes.status === 'rejected') toast(t('dashboard.servicesLoadFail'), 'error')
if (versionRes.status === 'rejected') toast(t('dashboard.versionLoadFail'), 'error')
@@ -138,15 +167,29 @@ async function loadDashboardData(page, fullRefresh = false) {
}
renderStatCards(page, services, version, [], config)
if (gw) {
maybeShowForeignGatewayBindingPrompt({
service: gw,
onRefresh: () => loadDashboardData(page, true),
}).catch(() => {})
}
// 第二波Agent、MCP、备份 → 更新卡片 + 渲染总览
const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP
const [agentsRes, mcpRes, backupsRes] = await secondaryP
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
const statusSummary = (statusRes.status === 'fulfilled' && statusRes.value)
? (_dashboardStatusSummaryCache = statusRes.value)
: _dashboardStatusSummaryCache
let statusSummary = null
if (shouldLoadStatusSummary) {
try {
statusSummary = (!_dashboardInitialized || fullRefresh || !_dashboardStatusSummaryCache)
? await withTimeout(api.getStatusSummary(), 15000)
: _dashboardStatusSummaryCache
_dashboardStatusSummaryCache = statusSummary
} catch {
statusSummary = _dashboardStatusSummaryCache
}
}
renderStatCards(page, services, version, agents, config)
renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
@@ -158,9 +201,21 @@ async function loadDashboardData(page, fullRefresh = false) {
_dashboardInitialized = true
}
async function openGatewayConflict(page, error = null, reason = null) {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
await showGatewayConflictGuidance({
error,
service: gw,
reason,
onRefresh: async () => loadDashboardData(page, true),
})
}
function renderStatCards(page, services, version, agents, config) {
const cardsEl = page.querySelector('#stat-cards')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const foreignGateway = isForeignGatewayService(gw)
const runningCount = services.filter(s => s.running).length
const versionMeta = version.recommended
? `${version.ahead_of_recommended ? t('dashboard.versionAhead', { version: version.recommended }) : version.is_recommended ? t('dashboard.versionStable', { version: version.recommended }) : t('dashboard.versionRecommend', { version: version.recommended })}${version.latest_update_available && version.latest ? ' · ' + t('dashboard.versionLatest', { version: version.latest }) : ''}`
@@ -168,7 +223,7 @@ function renderStatCards(page, services, version, agents, config) {
// CLI 路径信息
const cliSourceLabel = { standalone: t('dashboard.cliSourceStandalone'), 'npm-zh': t('dashboard.cliSourceNpmZh'), 'npm-official': t('dashboard.cliSourceNpmOfficial'), 'npm-global': t('dashboard.cliSourceNpmGlobal') }[version.cli_source] || t('dashboard.cliSourceUnknown')
const installCount = version.all_installations?.length || 0
const installCount = dedupeOpenclawInstallations(version.all_installations).length
const multiInstall = installCount > 1
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
@@ -181,8 +236,15 @@ function renderStatCards(page, services, version, agents, config) {
<span class="stat-card-label">${t('dashboard.gateway')}</span>
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? t('common.running') : t('common.stopped')}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? t('dashboard.portDetect') : t('dashboard.notStarted'))}</div>
<div class="stat-card-value">${foreignGateway ? t('dashboard.externalInstance') : gw?.running ? t('common.running') : t('common.stopped')}</div>
<div class="stat-card-meta">${foreignGateway ? t('dashboard.externalGatewayDetected', { pid: gw?.pid ? ' · PID ' + gw.pid : '' }) : gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? t('dashboard.portDetect') : t('dashboard.notStarted'))}</div>
${foreignGateway
? `<div class="stat-card-meta" style="margin-top:8px;color:var(--warning);line-height:1.6">${t('dashboard.foreignGatewayHint')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-secondary btn-xs" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
<button class="btn btn-primary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
</div>`
: ''}
</div>
<div class="stat-card">
<div class="stat-card-header">
@@ -191,6 +253,13 @@ function renderStatCards(page, services, version, agents, config) {
<div class="stat-card-value">${version.current || t('common.unknown')}</div>
<div class="stat-card-meta">${versionMeta}</div>
${version.cli_path ? `<div class="stat-card-meta" style="margin-top:2px;font-size:11px;opacity:0.7" title="${escapeHtml(version.cli_path)}">${cliSourceLabel}${multiInstall ? ' · <span style="color:var(--warning)">' + t('dashboard.installCount', { count: installCount }) + '</span>' : ''}</div>` : ''}
${multiInstall
? `<div class="stat-card-meta" style="margin-top:8px;color:var(--warning);line-height:1.6">${t('dashboard.multiInstallCardHint')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<button class="btn btn-secondary btn-xs" data-action="resolve-multi-install">${t('dashboard.viewGuidance')}</button>
<button class="btn btn-primary btn-xs" data-action="open-settings">${t('dashboard.goSettings')}</button>
</div>`
: ''}
</div>
<div class="stat-card">
<div class="stat-card-header">
@@ -227,6 +296,7 @@ function renderStatCards(page, services, version, agents, config) {
function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
const containerEl = page.querySelector('#dashboard-overview-container')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const foreignGateway = isForeignGatewayService(gw)
const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0
const formatDate = (timestamp) => {
@@ -243,6 +313,9 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
const lastUpdate = config?.meta?.lastTouchedVersion || t('common.unknown')
const runtimeVer = statusSummary?.runtimeVersion || null
const sessions = statusSummary?.sessions || null
const runtimeMeta = runtimeVer
? (statusSummary?.source === 'file-read' ? t('dashboard.runtimeMetaFileRead') : t('dashboard.runtimeMetaLive'))
: t('dashboard.runtimeMetaConfig')
const gwPort = config?.gateway?.port || 18789
const primaryModel = config?.agents?.defaults?.model?.primary || t('dashboard.notSet')
@@ -251,16 +324,18 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
<div class="dashboard-overview">
<div class="overview-grid">
<div class="overview-card" data-nav="/gateway">
<div class="overview-card-icon" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">
<div class="overview-card-icon" style="color:${foreignGateway ? 'var(--warning)' : gw?.running ? 'var(--success)' : 'var(--error)'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="overview-card-body">
<div class="overview-card-title">Gateway</div>
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? t('common.running') : t('common.stopped')}</div>
<div class="overview-card-meta">${t('dashboard.port')} ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
<div class="overview-card-value" style="color:${foreignGateway ? 'var(--warning)' : gw?.running ? 'var(--success)' : 'var(--error)'}">${foreignGateway ? t('dashboard.externalInstance') : gw?.running ? t('common.running') : t('common.stopped')}</div>
<div class="overview-card-meta">${foreignGateway ? `${t('dashboard.port')} ${gwPort}${gw?.pid ? ' · PID ' + gw.pid : ''} · ${t('dashboard.viewOnlyStatus')}` : `${t('dashboard.port')} ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}`}</div>
</div>
<div class="overview-card-actions">
${gw?.running
${foreignGateway
? '<button class="btn btn-secondary btn-xs" data-action="resolve-foreign-gateway">' + t('dashboard.viewGuidance') + '</button><button class="btn btn-primary btn-xs" data-action="open-settings">' + t('dashboard.goSettings') + '</button>'
: gw?.running
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">' + t('dashboard.stopBtn') + '</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">' + t('dashboard.restartBtn') + '</button>'
: '<button class="btn btn-primary btn-xs" data-action="start-gw">' + t('dashboard.startBtn') + '</button>'
}
@@ -318,7 +393,7 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
<div class="overview-card-body">
<div class="overview-card-title">${t('dashboard.runtimeVersion')}</div>
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${runtimeVer || lastUpdate}</div>
<div class="overview-card-meta">${runtimeVer ? 'OpenClaw Runtime' : 'openclaw.json'}</div>
<div class="overview-card-meta">${runtimeMeta}</div>
</div>
</div>
</div>
@@ -420,13 +495,31 @@ function bindActions(page) {
if (!actionBtn) return
const action = actionBtn.dataset.action
if (action === 'open-settings') {
navigate('/settings')
return
}
if (action === 'resolve-foreign-gateway') {
await openGatewayConflict(page, null, 'foreign-gateway')
return
}
if (action === 'resolve-multi-install') {
await openGatewayConflict(page, null, 'multiple-installations')
return
}
if (action === 'start-gw') {
actionBtn.disabled = true; actionBtn.textContent = t('dashboard.starting')
try {
await api.startService('ai.openclaw.gateway')
toast(t('dashboard.gwStartSent'), 'success')
setTimeout(() => loadDashboardData(page), 2000)
} catch (err) { toast(t('dashboard.startFail') + ': ' + err, 'error') }
} catch (err) {
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
else toast(t('dashboard.startFail') + ': ' + err, 'error')
}
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.startBtn') }
}
if (action === 'stop-gw') {
@@ -435,7 +528,10 @@ function bindActions(page) {
await api.stopService('ai.openclaw.gateway')
toast(t('dashboard.gwStopped'), 'success')
setTimeout(() => loadDashboardData(page), 1500)
} catch (err) { toast(t('dashboard.stopFail') + ': ' + err, 'error') }
} catch (err) {
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
else toast(t('dashboard.stopFail') + ': ' + err, 'error')
}
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.stopBtn') }
}
if (action === 'restart-gw') {
@@ -444,7 +540,10 @@ function bindActions(page) {
await api.restartService('ai.openclaw.gateway')
toast(t('dashboard.gwRestartSent'), 'success')
setTimeout(() => loadDashboardData(page), 3000)
} catch (err) { toast(t('dashboard.restartFail') + ': ' + err, 'error') }
} catch (err) {
if (isForeignGatewayError(err)) await openGatewayConflict(page, err)
else toast(t('dashboard.restartFail') + ': ' + err, 'error')
}
finally { actionBtn.disabled = false; actionBtn.textContent = t('dashboard.restartBtn') }
}
})
@@ -456,7 +555,8 @@ function bindActions(page) {
try {
await api.restartService('ai.openclaw.gateway')
} catch (e) {
toast(t('dashboard.restartFail') + ': ' + e, 'error')
if (isForeignGatewayError(e)) await openGatewayConflict(page, e)
else toast(t('dashboard.restartFail') + ': ' + e, 'error')
btnRestart.disabled = false
btnRestart.classList.remove('btn-loading')
btnRestart.textContent = t('dashboard.restartGw')

View File

@@ -236,7 +236,7 @@ async function saveFile(page, state) {
if (!state.currentPath) return
const content = page.querySelector('#file-editor').value
try {
await api.writeMemoryFile(state.currentPath, content, null, state.agentId)
await api.writeMemoryFile(state.currentPath, content, state.category, state.agentId)
toast(t('memory.fileSaved'), 'success')
} catch (e) {
toast(t('memory.saveFailed') + ': ' + e, 'error')

View File

@@ -50,7 +50,7 @@ async function apiCall(cmd, args = {}) {
await api.writePanelConfig(cfg)
return { success: true }
}
throw new Error('未知命令: ' + cmd)
throw new Error(`${t('common.unknownCommand')}: ${cmd}`)
}
// Web 模式
const resp = await fetch(`/__api/${cmd}`, {

View File

@@ -4,8 +4,9 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { showConfirm, showModal, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
@@ -31,6 +32,11 @@ export async function render() {
</div>
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
<div class="config-section" id="docker-manager-section">
<div class="config-section-title">${t('services.dockerManager')}</div>
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.dockerManagerHint')}</div>
<div id="docker-manager-bar"><div class="stat-card loading-placeholder" style="height:96px"></div></div>
</div>
<div class="config-section" id="config-editor-section" style="display:none">
<div class="config-section-title">${t('services.configEditor')}</div>
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configEditorHint')}</div>
@@ -58,7 +64,7 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadVersion(page), loadServices(page), loadBackups(page), loadConfigEditor(page)]
const tasks = [loadVersion(page), loadServices(page), loadDockerManager(page), loadBackups(page), loadConfigEditor(page)]
await Promise.all(tasks)
}
@@ -71,7 +77,10 @@ let lastVersionInfo = null
async function loadVersion(page) {
const bar = page.querySelector('#version-bar')
try {
const info = await api.getVersionInfo()
const [info, panelConfig] = await Promise.all([
api.getVersionInfo(),
api.readPanelConfig().catch(() => ({})),
])
lastVersionInfo = info
detectedSource = info.source || 'chinese'
const ver = info.current || t('common.unknown')
@@ -82,6 +91,7 @@ async function loadVersion(page) {
const sourceTag = isChinese ? t('services.chineseEdition') : t('services.officialEdition')
const switchLabel = isChinese ? t('services.switchToOfficial') : t('services.switchToChinese')
const switchTarget = isChinese ? 'official' : 'chinese'
const dockerImage = (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
const policyNote = aheadOfRecommended
? t('services.policyAhead', { ver, recommended: info.recommended })
: t('services.policyDefault')
@@ -96,7 +106,7 @@ async function loadVersion(page) {
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '' + t('services.pullNewImage') + '' : t('services.currentImageVer')}</div>
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">${escapeHtml(`docker pull ${dockerImage}:latest`)}</code>
</div>` : ''}
</div>
</div>
@@ -131,6 +141,123 @@ async function loadVersion(page) {
}
}
function configuredDockerImage(panelConfig) {
return (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
}
function formatDockerBytes(bytes) {
const value = Number(bytes || 0)
if (!Number.isFinite(value) || value <= 0) return '0 B'
if (value >= 1024 * 1024 * 1024) return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
return `${value} B`
}
function parseOptionalPort(value) {
const raw = String(value || '').trim()
if (!raw) return null
const num = Number(raw)
if (!Number.isInteger(num) || num < 1 || num > 65535) throw new Error(t('services.invalidPort', { value: raw }))
return num
}
async function hasDockerManagerBackend() {
try {
const resp = await fetch('/__api/health', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const ct = (resp.headers.get('content-type') || '').toLowerCase()
return resp.ok && !ct.includes('text/html') && !ct.includes('text/plain')
} catch {
return false
}
}
async function loadDockerManager(page) {
const bar = page.querySelector('#docker-manager-bar')
if (!bar) return
const backendReady = await hasDockerManagerBackend()
if (!backendReady) {
bar.innerHTML = `<div class="stat-card"><div class="stat-card-meta">${t('services.dockerManagerUnavailable')}</div></div>`
return
}
try {
const [overview, panelConfig] = await Promise.all([
api.dockerClusterOverview(),
api.readPanelConfig().catch(() => ({})),
])
const totalNodes = overview.length
const onlineNodes = overview.filter(node => node.online).length
const totalContainers = overview.reduce((sum, node) => sum + (node.containers?.length || 0), 0)
const runningContainers = overview.reduce((sum, node) => sum + (node.containers?.filter?.(ct => ct.state === 'running').length || 0), 0)
bar.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;gap:var(--space-sm);flex-wrap:wrap;margin-bottom:var(--space-md)">
<div class="stat-card" style="padding:12px 16px;min-width:260px">
<div class="stat-card-label">${t('services.dockerManager')}</div>
<div class="stat-card-meta">${onlineNodes}/${totalNodes} ${t('services.dockerOnline')} · ${runningContainers}/${totalContainers} ${t('services.dockerContainersLabel')}</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" data-action="docker-refresh">${t('services.dockerRefresh')}</button>
<button class="btn btn-secondary btn-sm" data-action="docker-add-node">${t('services.dockerAddNode')}</button>
<button class="btn btn-secondary btn-sm" data-action="docker-pull-image">${t('services.dockerPullAction')}</button>
<button class="btn btn-primary btn-sm" data-action="docker-create-container">${t('services.dockerCreateContainer')}</button>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-md)">
${overview.map(node => {
const containers = node.containers || []
const nodeMeta = node.online
? `${escapeHtml(node.endpoint || '')} · Docker ${escapeHtml(node.dockerVersion || t('common.unknown'))} · ${formatDockerBytes(node.memory)} · CPU ${node.cpus || 0}`
: `${escapeHtml(node.endpoint || '')} · ${escapeHtml(node.error || t('services.dockerOffline'))}`
return `
<div class="service-card" data-docker-node="${escapeHtml(node.id)}" style="display:block">
<div style="display:flex;justify-content:space-between;gap:var(--space-sm);align-items:flex-start;flex-wrap:wrap">
<div class="service-info">
<span class="status-dot ${node.online ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${escapeHtml(node.name)}${node.id === 'local' ? ` <span class="clawhub-badge" style="margin-left:6px;background:rgba(99,102,241,0.14);color:#6366f1">${t('services.dockerLocalNode')}</span>` : ''}</div>
<div class="service-desc">${nodeMeta}</div>
<div class="service-desc">${node.online ? `${t('services.dockerContainersLabel')}: ${node.runningContainers || 0}/${node.totalContainers || containers.length}` : t('services.dockerOffline')}</div>
</div>
</div>
<div class="service-actions">
${node.id !== 'local' ? `<button class="btn btn-danger btn-sm" data-action="docker-remove-node" data-node-id="${escapeHtml(node.id)}" data-name="${escapeHtml(node.name)}">${t('common.delete')}</button>` : ''}
</div>
</div>
<div style="margin-top:var(--space-sm);display:flex;flex-direction:column;gap:8px">
${containers.length ? containers.map(ct => `
<div class="service-card" style="background:var(--bg-secondary);border:1px solid var(--border-primary)">
<div class="service-info">
<span class="status-dot ${ct.state === 'running' ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${escapeHtml(ct.name)}</div>
<div class="service-desc">${escapeHtml(ct.image)} · ${escapeHtml(ct.status || ct.state || t('common.unknown'))}${ct.ports ? ` · ${escapeHtml(ct.ports)}` : ''}</div>
</div>
</div>
<div class="service-actions">
${ct.state === 'running'
? `<button class="btn btn-secondary btn-sm" data-action="docker-restart-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.restart')}</button>
<button class="btn btn-secondary btn-sm" data-action="docker-stop-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.stop')}</button>`
: `<button class="btn btn-primary btn-sm" data-action="docker-start-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.start')}</button>`}
<button class="btn btn-danger btn-sm" data-action="docker-remove-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}" data-running="${ct.state === 'running' ? '1' : ''}">${t('common.delete')}</button>
</div>
</div>
`).join('') : `<div class="form-hint" style="padding:4px 0">${t('services.dockerNoContainers')}</div>`}
</div>
</div>
`
}).join('')}
</div>
<div class="form-hint" style="margin-top:var(--space-sm)">${t('services.dockerDefaultImageHint')} <code>${escapeHtml(configuredDockerImage(panelConfig))}</code></div>
`
} catch (e) {
bar.innerHTML = `<div class="stat-card"><div class="stat-card-meta" style="color:var(--error)">${t('services.dockerManagerLoadFailed')}: ${escapeHtml(e?.message || e)}</div></div>`
}
}
// ===== 服务列表 =====
async function loadServices(page) {
@@ -138,11 +265,174 @@ async function loadServices(page) {
try {
const services = await api.getServicesStatus()
renderServices(container, services)
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
if (gw) {
maybeShowForeignGatewayBindingPrompt({
service: gw,
onRefresh: () => loadServices(page),
}).catch(() => {})
}
} catch (e) {
container.innerHTML = `<div style="color:var(--error)">${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}</div>`
}
}
async function openDockerAddNode(page) {
showModal({
title: t('services.dockerAddNode'),
fields: [
{ name: 'name', label: t('services.dockerNodeName'), value: '', placeholder: 'docker-node-1' },
{ name: 'endpoint', label: t('services.dockerNodeEndpoint'), value: '', placeholder: 'tcp://192.168.1.20:2375' },
],
onConfirm: async ({ name, endpoint }) => {
try {
await api.dockerAddNode((name || '').trim(), (endpoint || '').trim())
toast(t('services.dockerNodeAdded'), 'success')
await loadDockerManager(page)
} catch (e) {
toast(e?.message || e, 'error')
}
},
})
}
async function openDockerPullImage(page) {
const [nodes, panelConfig] = await Promise.all([
api.dockerListNodes(),
api.readPanelConfig().catch(() => ({})),
])
showModal({
title: t('services.dockerPullTitle'),
fields: [
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
],
onConfirm: async ({ nodeId, image, tag }) => {
const requestId = `pull-${Date.now()}`
const modal = showUpgradeModal(t('services.dockerPullTitle'))
let lastMessage = ''
const timer = setInterval(async () => {
try {
const status = await api.dockerPullStatus(requestId)
if (Number.isFinite(status?.percent)) modal.setProgress(status.percent)
if (status?.message && status.message !== lastMessage) {
lastMessage = status.message
modal.appendLog(status.message)
}
} catch {}
}, 800)
try {
const result = await api.dockerPullImage({
nodeId: nodeId || null,
image: (image || '').trim() || configuredDockerImage(panelConfig),
tag: (tag || '').trim() || 'latest',
requestId,
})
clearInterval(timer)
modal.setProgress(100)
if (result?.message) modal.appendLog(result.message)
modal.setDone(t('services.dockerPullDone'))
toast(t('services.dockerPullDone'), 'success')
await loadDockerManager(page)
} catch (e) {
clearInterval(timer)
modal.appendLog(e?.message || String(e))
modal.setError(e?.message || String(e))
toast(e?.message || e, 'error')
}
},
})
}
async function openDockerCreateContainer(page) {
const [nodes, panelConfig] = await Promise.all([
api.dockerListNodes(),
api.readPanelConfig().catch(() => ({})),
])
showModal({
title: t('services.dockerCreateTitle'),
fields: [
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
{ name: 'name', label: t('services.dockerContainerNameLabel'), value: '', placeholder: 'openclaw-worker-1' },
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
{ name: 'panelPort', label: t('services.dockerPanelPortLabel'), value: '1420', hint: t('services.dockerPortOptionalHint') },
{ name: 'gatewayPort', label: t('services.dockerGatewayPortLabel'), value: '18789', hint: t('services.dockerPortOptionalHint') },
{ name: 'volume', type: 'checkbox', label: t('services.dockerUseVolume'), value: true },
],
onConfirm: async ({ nodeId, name, image, tag, panelPort, gatewayPort, volume }) => {
try {
await api.dockerCreateContainer({
nodeId: nodeId || null,
name: (name || '').trim() || undefined,
image: (image || '').trim() || configuredDockerImage(panelConfig),
tag: (tag || '').trim() || 'latest',
panelPort: parseOptionalPort(panelPort),
gatewayPort: parseOptionalPort(gatewayPort),
volume: !!volume,
})
toast(t('services.dockerContainerCreated'), 'success')
await loadDockerManager(page)
} catch (e) {
toast(e?.message || e, 'error')
}
},
})
}
async function handleDockerRemoveNode(btn, page) {
const name = btn.dataset.name || btn.dataset.nodeId || ''
const yes = await showConfirm(t('services.dockerRemoveNodeConfirm', { name }))
if (!yes) return
await api.dockerRemoveNode(btn.dataset.nodeId)
toast(t('services.dockerNodeRemoved'), 'success')
await loadDockerManager(page)
}
async function handleDockerContainerAction(action, btn, page) {
const nodeId = btn.dataset.nodeId || null
const containerId = btn.dataset.containerId
const name = btn.dataset.name || containerId
if (!containerId) throw new Error(t('services.missingContainerId'))
if (action === 'docker-remove-container') {
const yes = await showConfirm(t('services.dockerRemoveContainerConfirm', { name }))
if (!yes) return
await api.dockerRemoveContainer(nodeId, containerId, btn.dataset.running === '1')
toast(t('services.dockerContainerRemoved'), 'success')
await loadDockerManager(page)
return
}
const label = {
'docker-start-container': t('services.start'),
'docker-stop-container': t('services.stop'),
'docker-restart-container': t('services.restart'),
}[action]
const fn = {
'docker-start-container': api.dockerStartContainer,
'docker-stop-container': api.dockerStopContainer,
'docker-restart-container': api.dockerRestartContainer,
}[action]
await fn(nodeId, containerId)
toast(t('services.actionDone', { label: name, action: label }), 'success')
await loadDockerManager(page)
}
async function openGatewayConflict(page, error = null) {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
await showGatewayConflictGuidance({
error,
service: gw,
onRefresh: async () => {
await loadVersion(page)
await loadServices(page)
},
})
}
function renderServices(container, services) {
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
@@ -150,6 +440,8 @@ function renderServices(container, services) {
if (gw) {
// 检测 CLI 是否安装
const cliMissing = gw.cli_installed === false
const foreignGateway = !cliMissing && isForeignGatewayService(gw)
const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : ''
html += `
<div class="service-card" data-label="${gw.label}">
@@ -159,6 +451,8 @@ function renderServices(container, services) {
<div class="service-name">${gw.label}</div>
<div class="service-desc">${cliMissing
? t('services.cliNotInstalled')
: foreignGateway
? t('services.foreignGatewayDesc', { pid: foreignPidText, settings: t('sidebar.settings') })
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
}</div>
</div>
@@ -170,6 +464,14 @@ function renderServices(container, services) {
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">${t('services.refreshStatus')}</button>
</div>`
: foreignGateway
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
<div style="color:var(--warning);font-size:var(--font-size-xs);max-width:320px;text-align:right">${t('services.foreignGatewayHint')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary btn-sm" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
<button class="btn btn-secondary btn-sm" data-action="refresh-services">${t('services.refreshStatus')}</button>
</div>
</div>`
: gw.running
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">${t('services.restart')}</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">${t('services.stop')}</button>
@@ -283,6 +585,30 @@ function bindEvents(page) {
case 'refresh-services':
await loadServices(page)
break
case 'resolve-foreign-gateway':
await openGatewayConflict(page)
break
case 'docker-refresh':
await loadDockerManager(page)
break
case 'docker-add-node':
await openDockerAddNode(page)
break
case 'docker-pull-image':
await openDockerPullImage(page)
break
case 'docker-create-container':
await openDockerCreateContainer(page)
break
case 'docker-remove-node':
await handleDockerRemoveNode(btn, page)
break
case 'docker-start-container':
case 'docker-stop-container':
case 'docker-restart-container':
case 'docker-remove-container':
await handleDockerContainerAction(action, btn, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -333,7 +659,11 @@ async function handleServiceAction(action, label, page) {
try {
await fn(label)
} catch (e) {
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
if (isForeignGatewayError(e)) {
await openGatewayConflict(page, e)
} else {
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
}
if (actionsEl) actionsEl.innerHTML = origHtml
if (dot) dot.className = 'status-dot stopped'
return

View File

@@ -6,6 +6,7 @@ import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18n.js'
import { isMacPlatform } from '../lib/app-state.js'
import { renderSidebar } from '../components/sidebar.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -15,6 +16,44 @@ function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function platformDefaultDockerEndpoint() {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
return isWin ? '//./pipe/docker_engine' : '/var/run/docker.sock'
}
function effectiveDockerEndpoint(cfg) {
return (cfg?.dockerEndpoint || '').trim() || platformDefaultDockerEndpoint()
}
function effectiveDockerImage(cfg) {
return (cfg?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
}
function openclawInstallationIdentity(installation) {
const rawPath = String(installation?.path || '').trim()
if (!rawPath) return ''
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
if (!isWin) return rawPath
return rawPath
.replace(/\//g, '\\')
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
.toLowerCase()
}
function dedupeOpenclawInstallations(list = []) {
const map = new Map()
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
for (const installation of Array.isArray(list) ? list : []) {
const key = openclawInstallationIdentity(installation)
if (!key) continue
const existing = map.get(key)
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
map.set(key, installation)
}
}
return [...map.values()]
}
const REGISTRIES = [
{ label: () => t('settings.registryTaobao'), value: 'https://registry.npmmirror.com' },
{ label: () => t('settings.registryNpm'), value: 'https://registry.npmjs.org' },
@@ -51,6 +90,16 @@ export async function render() {
<div id="openclaw-dir-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="openclaw-search-section">
<div class="config-section-title">${t('settings.openclawSearchPaths')}</div>
<div id="openclaw-search-bar"><div class="stat-card loading-placeholder" style="height:96px"></div></div>
</div>
<div class="config-section" id="docker-defaults-section">
<div class="config-section-title">${t('settings.dockerDefaults')}</div>
<div id="docker-defaults-bar"><div class="stat-card loading-placeholder" style="height:84px"></div></div>
</div>
<div class="config-section" id="cli-binding-section">
<div class="config-section-title">${t('settings.openclawCli')}</div>
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
@@ -62,7 +111,7 @@ export async function render() {
</div>
${window.__TAURI_INTERNALS__ ? `<div class="config-section" id="autostart-section">
<div class="config-section-title">${t('settings.autostart') || '开机自启'}</div>
<div class="config-section-title">${t('settings.autostart')}</div>
<div id="autostart-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>` : ''}
@@ -74,7 +123,7 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadCliBinding(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadCliBinding(page)]
tasks.push(loadRegistry(page))
if (window.__TAURI_INTERNALS__) tasks.push(loadAutostart(page))
await Promise.all(tasks)
@@ -171,7 +220,7 @@ async function loadOpenclawDir(page) {
const bar = page.querySelector('#openclaw-dir-bar')
if (!bar) return
try {
const info = isTauri ? await api.getOpenclawDir() : { path: '~/.openclaw', isCustom: false, configExists: true }
const info = await api.getOpenclawDir()
const cfg = await api.readPanelConfig()
const customValue = cfg?.openclawDir || ''
const statusText = info.configExists
@@ -209,7 +258,14 @@ async function handleSaveOpenclawDir(page) {
}
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await promptRestart(value ? t('settings.customPathSaved') : t('settings.defaultRestored'))
await loadCliBinding(page)
const savedMsg = value ? t('settings.customPathSaved') : t('settings.defaultRestored')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) {
toast(savedMsg, 'success')
return
}
await promptRestart(savedMsg)
}
async function handleResetOpenclawDir(page) {
@@ -217,9 +273,145 @@ async function handleResetOpenclawDir(page) {
delete cfg.openclawDir
await api.writePanelConfig(cfg)
await loadOpenclawDir(page)
await loadCliBinding(page)
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) {
toast(t('settings.defaultRestored'), 'success')
return
}
await promptRestart(t('settings.defaultRestored'))
}
async function loadOpenclawSearchPaths(page) {
const bar = page.querySelector('#openclaw-search-bar')
if (!bar) return
try {
const cfg = await api.readPanelConfig()
const value = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths.join('\n') : ''
bar.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-sm)">
<textarea class="form-input" data-name="openclaw-search-paths" rows="4" placeholder="${t('settings.searchPathsPlaceholder')}" style="max-width:680px;min-height:108px;resize:vertical">${escapeHtml(value)}</textarea>
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<button class="btn btn-primary btn-sm" data-action="save-openclaw-search-paths">${t('common.save')}</button>
</div>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
${t('settings.searchPathsHint')}
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
}
}
function parseOpenclawSearchPaths(raw) {
const values = []
const seen = new Set()
for (const part of String(raw || '').split(/[\r\n;]+/)) {
const value = part.trim()
if (!value) continue
const key = value.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
values.push(value)
}
return values
}
async function handleSaveOpenclawSearchPaths(page) {
const input = page.querySelector('[data-name="openclaw-search-paths"]')
const paths = parseOpenclawSearchPaths(input?.value || '')
const cfg = await api.readPanelConfig()
if (paths.length > 0) {
cfg.openclawSearchPaths = paths
} else {
delete cfg.openclawSearchPaths
}
await api.writePanelConfig(cfg)
await loadOpenclawSearchPaths(page)
await loadCliBinding(page)
toast(paths.length > 0 ? t('settings.searchPathsSaved') : t('settings.searchPathsCleared'), 'success')
}
async function loadDockerDefaults(page) {
const bar = page.querySelector('#docker-defaults-bar')
if (!bar) return
try {
const cfg = await api.readPanelConfig()
const endpoint = cfg?.dockerEndpoint || ''
const image = cfg?.dockerDefaultImage || ''
const currentEndpoint = effectiveDockerEndpoint(cfg)
const currentImage = effectiveDockerImage(cfg)
bar.innerHTML = `
<div style="margin-bottom:var(--space-xs);display:flex;flex-direction:column;gap:4px">
<div><span class="form-hint">${t('settings.currentDefault')}:</span> <code style="font-size:var(--font-size-xs)">${escapeHtml(currentEndpoint)}</code></div>
<div><span class="form-hint">${t('settings.dockerDefaultImage')}:</span> <code style="font-size:var(--font-size-xs)">${escapeHtml(currentImage)}</code></div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-sm)">
<input class="form-input" data-name="docker-endpoint" placeholder="${t('settings.dockerEndpointPlaceholder')}" value="${escapeHtml(endpoint)}" style="max-width:680px">
<input class="form-input" data-name="docker-default-image" placeholder="${t('settings.dockerDefaultImagePlaceholder')}" value="${escapeHtml(image)}" style="max-width:680px">
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<button class="btn btn-primary btn-sm" data-action="save-docker-defaults">${t('common.save')}</button>
</div>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
${t('settings.dockerDefaultsHint')}
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
}
}
async function handleSaveDockerDefaults(page) {
const endpointInput = page.querySelector('[data-name="docker-endpoint"]')
const imageInput = page.querySelector('[data-name="docker-default-image"]')
const endpoint = (endpointInput?.value || '').trim()
const image = (imageInput?.value || '').trim()
const cfg = await api.readPanelConfig()
if (endpoint) cfg.dockerEndpoint = endpoint
else delete cfg.dockerEndpoint
if (image) cfg.dockerDefaultImage = image
else delete cfg.dockerDefaultImage
await api.writePanelConfig(cfg)
await loadDockerDefaults(page)
toast(t('settings.dockerDefaultsSaved'), 'success')
}
async function maybeRefreshGatewayServiceBinding() {
if (!isMacPlatform()) return false
const [versionInfo, dirInfo] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.getOpenclawDir().catch(() => null),
])
if (!versionInfo?.cli_path || dirInfo?.configExists === false) {
return false
}
const shouldRefresh = await showConfirm(t('settings.gatewayServiceRefreshConfirm'))
if (!shouldRefresh) return false
toast(t('settings.gatewayServiceRefreshing'), 'info')
try {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
const shouldStartAgain = gw?.running === true && gw?.owned_by_current_instance !== false
await api.uninstallGateway().catch(() => {})
await api.installGateway()
if (shouldStartAgain) {
await api.startService('ai.openclaw.gateway')
}
toast(t('settings.gatewayServiceRefreshed'), 'success')
return true
} catch (e) {
toast(`${t('settings.gatewayServiceRefreshFailed')}: ${e?.message || e}`, 'warning')
return false
}
}
async function promptRestart(msg) {
if (!isTauri) { toast(msg, 'success'); return }
const ok = await showConfirm(`${msg}\n\n${t('settings.restartConfirm')}`)
@@ -262,6 +454,12 @@ function bindEvents(page) {
case 'reset-openclaw-dir':
await handleResetOpenclawDir(page)
break
case 'save-openclaw-search-paths':
await handleSaveOpenclawSearchPaths(page)
break
case 'save-docker-defaults':
await handleSaveDockerDefaults(page)
break
case 'bind-cli':
await handleBindCli(page, btn.dataset.path)
break
@@ -359,7 +557,7 @@ async function loadCliBinding(page) {
const version = await api.getVersionInfo()
const cfg = await api.readPanelConfig()
const boundPath = cfg?.openclawCliPath || ''
const installations = version.all_installations || []
const installations = dedupeOpenclawInstallations(version.all_installations || [])
const currentPath = version.cli_path || ''
const sourceLabel = (src) => ({
@@ -417,6 +615,7 @@ async function handleBindCli(page, path) {
await api.writePanelConfig(cfg)
toast(t('common.saveSuccess'), 'success')
await loadCliBinding(page)
await maybeRefreshGatewayServiceBinding()
}
async function handleUnbindCli(page) {
@@ -425,6 +624,7 @@ async function handleUnbindCli(page) {
await api.writePanelConfig(cfg)
toast(t('common.saveSuccess'), 'success')
await loadCliBinding(page)
await maybeRefreshGatewayServiceBinding()
}
// ===== 语言切换 =====
@@ -468,28 +668,28 @@ async function loadAutostart(page) {
<div style="display:flex;align-items:center;gap:var(--space-sm)">
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);cursor:pointer">
<input type="checkbox" id="autostart-toggle" ${enabled ? 'checked' : ''}>
${t('settings.autostartToggle') || '系统启动时自动运行 ClawPanel'}
${t('settings.autostartToggle')}
</label>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
${t('settings.autostartHint') || '开启后,电脑重启时 ClawPanel 会自动启动并检测 Gateway 状态'}
${t('settings.autostartHint')}
</div>
`
bar.querySelector('#autostart-toggle')?.addEventListener('change', async (e) => {
try {
if (e.target.checked) {
await enable()
toast(t('settings.autostartEnabled') || '已开启开机自启', 'success')
toast(t('settings.autostartEnabled'), 'success')
} else {
await disable()
toast(t('settings.autostartDisabled') || '已关闭开机自启', 'success')
toast(t('settings.autostartDisabled'), 'success')
}
} catch (err) {
e.target.checked = !e.target.checked
toast((t('settings.autostartFailed') || '设置失败') + ': ' + err, 'error')
toast(t('settings.autostartFailed') + ': ' + err, 'error')
}
})
} catch {
bar.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.autostartUnavailable') || '当前环境不支持开机自启'}</div>`
bar.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.autostartUnavailable')}</div>`
}
}

View File

@@ -10,28 +10,92 @@ import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
function escapeHtml(str) {
if (str == null) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function openclawSourceLabel(src) {
return ({
standalone: t('dashboard.cliSourceStandalone'),
'npm-zh': t('dashboard.cliSourceNpmZh'),
'npm-official': t('dashboard.cliSourceNpmOfficial'),
'npm-global': t('dashboard.cliSourceNpmGlobal'),
})[src] || t('dashboard.cliSourceUnknown')
}
function parseOpenclawSearchPaths(raw) {
const values = []
const seen = new Set()
for (const part of String(raw || '').split(/[\r\n;]+/)) {
const value = part.trim()
if (!value) continue
const key = value.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
values.push(value)
}
return values
}
function buildStatusMeta(...parts) {
return parts
.map(part => String(part || '').trim())
.filter(Boolean)
.join(' · ')
}
function renderDetectionHint(pathValue, sourceLabel = '') {
const normalizedPath = String(pathValue || '').trim()
const normalizedSource = String(sourceLabel || '').trim()
if (!normalizedPath && !normalizedSource) return ''
return `
<div class="setup-inline-note" style="margin-top:8px;line-height:1.6">
${normalizedPath ? `<div><span style="color:var(--text-secondary)">${t('setup.detectedPathLabel')}:</span> <code style="font-size:11px">${escapeHtml(normalizedPath)}</code></div>` : ''}
${normalizedSource ? `<div${normalizedPath ? ' style="margin-top:4px"' : ''}><span style="color:var(--text-secondary)">${t('setup.detectedFromLabel')}:</span> ${escapeHtml(normalizedSource)}</div>` : ''}
</div>
`
}
function renderStatusCard(title, ok, meta) {
return `
<div class="setup-status-card ${ok ? 'is-ok' : 'is-pending'}">
<div class="setup-status-icon">${ok ? '✓' : '✦'}</div>
<div class="setup-status-body">
<div class="setup-status-title">${title}</div>
<div class="setup-status-meta">${escapeHtml(meta)}</div>
</div>
</div>
`
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div style="max-width:560px;margin:48px auto;text-align:center">
<div style="margin-bottom:var(--space-lg)">
<img src="/images/logo-brand.png" alt="ClawPanel" style="max-width:160px;width:100%;height:auto">
<div class="setup-shell">
<div class="setup-hero">
<div class="setup-hero-brand">
<img src="/images/logo-brand.png" alt="ClawPanel" class="setup-hero-logo">
<div class="setup-hero-copy">
<h1 class="setup-hero-title">${t('setup.headerTitle')}</h1>
<p class="setup-hero-desc">${t('setup.headerDesc')}</p>
</div>
</div>
<div class="setup-hero-actions">
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
${t('setup.recheck')}
</button>
</div>
</div>
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">${t('setup.headerTitle')}</h1>
<p style="color:var(--text-secondary);margin-bottom:var(--space-xl);line-height:1.6">
${t('setup.headerDesc')}
</p>
<div id="setup-steps"></div>
<div style="margin-top:var(--space-lg)">
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
${t('setup.recheck')}
</button>
</div>
</div>
`
@@ -97,76 +161,100 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
const nodeOk = node.installed
const gitOk = git?.installed || false
const allOk = nodeOk && cliOk && config.installed
const nodeStatusMeta = nodeOk
? buildStatusMeta(node.version || t('setup.statusReady'), node.path)
: t('setup.statusActionNeeded')
const gitStatusMeta = gitOk
? buildStatusMeta(git.version || t('setup.statusReady'), git.path)
: t('setup.statusActionNeeded')
const cliPrimaryMeta = cliOk
? buildStatusMeta(version?.cli_source ? openclawSourceLabel(version.cli_source) : '', version?.current ? `v${version.current}` : t('setup.statusReady'))
: ''
const cliStatusMeta = cliOk
? buildStatusMeta(cliPrimaryMeta, version?.cli_path)
: t('setup.statusActionNeeded')
const configStatusMeta = config.installed
? (config.path || t('setup.statusReady'))
: t('setup.statusActionNeeded')
let html = ''
const statusCards = [
renderStatusCard(t('setup.stepNode'), nodeOk, nodeStatusMeta),
renderStatusCard(t('setup.stepGit'), gitOk, gitStatusMeta),
renderStatusCard('OpenClaw CLI', cliOk, cliStatusMeta),
renderStatusCard(t('setup.stepConfig'), config.installed, configStatusMeta),
].join('')
let html = `
<div class="setup-status-grid">${statusCards}</div>
<div class="setup-main-grid">
<div class="setup-column">
`
// 第一步Node.js
html += `
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(nodeOk)} ${t('setup.stepNode')}
if (!nodeOk) {
html += `
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(nodeOk)} ${t('setup.stepNode')}
</div>
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
${t('setup.stepNodeHint')}
</p>
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">${t('setup.downloadNode')}</a>
<span class="form-hint" style="margin-left:8px">${t('setup.recheckAfterInstall')}</span>
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.6">
<strong>${t('setup.nodeInstalledButNotDetected')}</strong>
${isMacPlatform()
? `${t('setup.macNodeHint')}<br>
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
: `${t('setup.winNodeHint')}`
}
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.scanNodeBtn')}</button>
<span style="color:var(--text-tertiary)">${t('setup.orManualPath')}</span>
</div>
<div class="setup-input-row" style="margin-top:6px">
<input id="input-node-path" type="text" placeholder="${isMacPlatform() ? '/usr/local/bin' : 'F:\\AI\\Node'}"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-check-path" style="font-size:11px;padding:3px 10px">${t('setup.checkPathBtn')}</button>
</div>
<div id="scan-result" style="margin-top:6px;display:none"></div>
</div>
</div>
${nodeOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${node.version || ''}</p>`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
${t('setup.stepNodeHint')}
</p>
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">${t('setup.downloadNode')}</a>
<span class="form-hint" style="margin-left:8px">${t('setup.recheckAfterInstall')}</span>
<div style="margin-top:var(--space-sm);padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.6">
<strong>${t('setup.nodeInstalledButNotDetected')}</strong>
${isMacPlatform()
? `${t('setup.macNodeHint')}<br>
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
: `${t('setup.winNodeHint')}`
}
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.scanNodeBtn')}</button>
<span style="color:var(--text-tertiary)">${t('setup.orManualPath')}</span>
</div>
<div style="margin-top:6px;display:flex;gap:6px">
<input id="input-node-path" type="text" placeholder="${isMacPlatform() ? '/usr/local/bin' : 'F:\\\\AI\\\\Node'}"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-check-path" style="font-size:11px;padding:3px 10px">${t('setup.checkPathBtn')}</button>
</div>
<div id="scan-result" style="margin-top:6px;display:none"></div>
</div>`
}
</div>
`
`
}
// 第二步Git
html += `
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(gitOk)} ${t('setup.stepGit')}
if (!gitOk) {
html += `
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.65;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(gitOk)} ${t('setup.stepGit')}
</div>
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
${t('setup.stepGitHint')}
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">${t('setup.autoInstallGitBtn')}</button>
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">${t('setup.manualDownload')}</a>
</div>
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
${t('setup.gitOptionalHint')}
</div>
</div>
${gitOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.installed')} ${git.version || ''}</p>
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ ${t('setup.gitHttpsConfigured')}</p>`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
${t('setup.stepGitHint')}
</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">${t('setup.autoInstallGitBtn')}</button>
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">${t('setup.manualDownload')}</a>
</div>
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
${t('setup.gitOptionalHint')}
</div>`
}
</div>
`
`
}
// 第三步OpenClaw CLI
html += `
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.65;pointer-events:none'}">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(cliOk)} OpenClaw CLI
</div>
${cliOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.cliAvailable')}</p>
${renderDetectionHint(version?.cli_path, version?.cli_source ? openclawSourceLabel(version.cli_source) : '')}
${version?.ahead_of_recommended && version?.recommended
? `<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--warning,#f59e0b);line-height:1.6">
${t('setup.cliAheadWarning', { current: version.current || '', recommended: version.recommended })}
@@ -176,18 +264,26 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
}
</div>
`
html += `
</div>
<div class="setup-column">
`
// 第四步:配置文件 + 自定义路径
html += `
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
${stepIcon(config.installed)} ${t('setup.stepConfig')}
</div>
${config.installed
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.configAt', { path: config.path || '' })}</p>`
? `<p style="color:var(--success);font-size:var(--font-size-sm)">${t('setup.configAt', { path: config.path || '' })}</p>
${renderDetectionHint(config.path)}`
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
${t('setup.configMissing')}
</p>
<button class="btn btn-primary btn-sm" id="btn-init-config">${t('setup.initConfigLabel')}</button>`
${renderDetectionHint(config.path)}
<button class="btn btn-primary btn-sm" id="btn-init-config" style="margin-top:10px">${t('setup.initConfigLabel')}</button>`
}
<details style="margin-top:var(--space-sm);cursor:pointer" id="custom-dir-details">
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
@@ -197,7 +293,8 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
<p style="color:var(--text-secondary);margin-bottom:8px">
${t('setup.customDirHint')}
</p>
<div style="display:flex;gap:6px">
<div class="setup-inline-note" style="margin-bottom:8px">${t('setup.customDirNotice')}</div>
<div class="setup-input-row">
<input id="input-openclaw-dir" type="text" placeholder="${t('setup.customDirPlaceholder')}"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-save-openclaw-dir" style="font-size:11px;padding:3px 10px">${t('setup.saveBtn')}</button>
@@ -211,7 +308,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
// AI 助手入口
html += `
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
<div class="config-section" style="text-align:left">
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
${t('setup.aiAssistant')}
@@ -232,6 +329,15 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
</div>
`
html += `
</div>
</div>
`
if (!cliOk) {
html += renderEnvironmentHint()
}
// 全部就绪 → 进入面板
if (allOk) {
html += `
@@ -262,107 +368,144 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
}
function renderInstallSection() {
return `
<div class="setup-search-panel">
<div style="font-weight:600;color:var(--text-primary);margin-bottom:4px">${t('setup.searchOpenclawTitle')}</div>
<div style="color:var(--text-secondary)">${t('setup.searchOpenclawDesc')}</div>
<div class="setup-input-row" style="margin-top:8px">
<button class="btn btn-secondary btn-sm" id="btn-scan-openclaw" style="font-size:11px;padding:3px 10px">${icon('search', 12)} ${t('setup.searchOpenclawBtn')}</button>
</div>
<div class="setup-inline-note" style="margin-top:12px">${t('setup.searchOpenclawHint')}</div>
<details style="margin-top:12px;cursor:pointer" id="advanced-openclaw-search-details">
<summary style="font-size:var(--font-size-xs);color:var(--text-secondary);font-weight:600;user-select:none">
${t('setup.searchOpenclawAdvancedTitle')}
</summary>
<div style="margin-top:var(--space-sm);display:flex;flex-direction:column;gap:12px">
<div class="setup-inline-note">${t('setup.searchOpenclawAdvancedHint')}</div>
<div>
<label style="font-size:var(--font-size-xs);color:var(--text-secondary);display:block;margin-bottom:6px">${t('setup.searchOpenclawExtraPathsLabel')}</label>
<textarea id="input-openclaw-search-paths" rows="3" placeholder="${t('setup.searchOpenclawExtraPathsPlaceholder')}"
style="width:100%;padding:6px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace;resize:vertical;min-height:78px"></textarea>
<div class="setup-input-row" style="margin-top:6px">
<button class="btn btn-secondary btn-sm" id="btn-save-openclaw-search-paths" style="font-size:11px;padding:3px 10px">${t('setup.searchOpenclawExtraPathsSave')}</button>
</div>
<div class="setup-inline-note">${t('setup.searchOpenclawExtraPathsHint')}</div>
<div id="openclaw-search-paths-result" style="margin-top:6px;display:none"></div>
</div>
<div>
<label style="font-size:var(--font-size-xs);color:var(--text-secondary);display:block;margin-bottom:6px">${t('setup.searchOpenclawManualLabel')}</label>
<div class="setup-input-row">
<input id="input-openclaw-cli-path" type="text" placeholder="${t('setup.searchOpenclawManualPlaceholder')}"
style="flex:1;padding:4px 8px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:11px;font-family:monospace">
<button class="btn btn-primary btn-sm" id="btn-check-openclaw-path" style="font-size:11px;padding:3px 10px">${t('setup.searchOpenclawManualBtn')}</button>
</div>
<div class="setup-inline-note">${t('setup.searchOpenclawManualHint')}</div>
</div>
</div>
</details>
<div id="scan-openclaw-result" style="margin-top:8px;display:none"></div>
</div>
<div class="setup-install-panel">
<div style="font-weight:600;color:var(--text-primary);margin-bottom:6px">${t('setup.installOpenclaw')}</div>
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
${t('setup.installHint')}
</p>
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
${t('setup.installHint2')}
</p>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceChineseLabel')}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
</div>
</label>
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="official" style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceOfficialLabel')}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
</div>
</label>
</div>
<div style="margin-bottom:var(--space-sm)" id="install-method-section">
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.installMethodLabel')}</label>
<select id="install-method" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
<option value="auto">${t('setup.methodAuto')}</option>
<option value="standalone-r2">${t('setup.methodStandaloneR2')}</option>
<option value="standalone-github">${t('setup.methodStandaloneGithub')}</option>
<option value="npm">${t('setup.methodNpm')}</option>
</select>
<div id="method-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px;line-height:1.5"></div>
</div>
<div style="margin-bottom:var(--space-sm)" id="registry-section">
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.registryLabel')}</label>
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
<option value="https://registry.npmmirror.com">${t('setup.registryTaobao')}</option>
<option value="https://registry.npmjs.org">${t('setup.registryNpm')}</option>
<option value="https://repo.huaweicloud.com/repository/npm/">${t('setup.registryHuawei')}</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="btn-install">${t('setup.installBtn')}</button>
</div>
`
}
function renderEnvironmentHint() {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const isMac = navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh')
const isDesktop = !!window.__TAURI_INTERNALS__
let envHint = ''
if (isDesktop) {
envHint = `
<div style="margin-top:var(--space-sm);padding:10px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid var(--warning);font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7">
<strong style="color:var(--text-primary)">${t('setup.envHintTitle')}</strong>
<p style="margin:6px 0 2px">${t('setup.envHintDesc')}</p>
<ul style="margin:4px 0 8px 16px;padding:0">
${isWin ? `
<li><strong>${t('setup.envHintWsl')}</strong> — ${t('setup.envHintWslDesc')}</li>
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
` : ''}
${isMac ? `
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
<li><strong>${t('setup.envHintRemote')}</strong> — ${t('setup.envHintRemoteDesc')}</li>
` : ''}
${!isWin && !isMac ? `
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
` : ''}
</ul>
<details style="cursor:pointer">
<summary style="font-weight:600;color:var(--primary);margin-bottom:6px">
${t('setup.envHintInstallManage')}
</summary>
<div style="margin-top:8px">
${isWin ? `
<div style="margin-bottom:10px">
<div style="font-weight:600;margin-bottom:4px">${t('setup.wslWebHint')}</div>
<div style="margin-bottom:2px;opacity:0.8">${t('setup.wslWebDesc')}</div>
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirror')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
<div style="margin-top:4px;opacity:0.7">${t('setup.wslWebPostDeploy')}</div>
</div>
` : ''}
<div style="margin-bottom:10px">
<div style="font-weight:600;margin-bottom:4px">${t('setup.dockerHint')}</div>
<div style="margin-bottom:2px;opacity:0.8">${t('setup.dockerDesc')}</div>
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all;margin-bottom:4px">npm i -g @qingchencloud/openclaw-zh</code>
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
</div>
<div>
<div style="font-weight:600;margin-bottom:4px">${t('setup.remoteHint')}</div>
<div style="margin-bottom:2px;opacity:0.8">${t('setup.remoteDesc')}</div>
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div style="margin-top:4px;opacity:0.7">${t('setup.domesticMirrorShort')}<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
</div>
</div>
</details>
<div style="margin-top:6px;opacity:0.7">
${t('setup.envHintLocalReinstall')}
</div>
</div>`
}
if (!isDesktop) return ''
return `
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
${t('setup.installHint')}
</p>
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
${t('setup.installHint2')}
</p>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceChineseLabel')}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
<div class="config-section-title">${t('setup.envHintTitle')}</div>
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);line-height:1.6;margin-bottom:var(--space-sm)">
${t('setup.envHintDesc')}
</p>
<details class="setup-help-details">
<summary>${t('setup.envHintInstallManage')}</summary>
<div class="setup-help-content">
<ul style="margin:0 0 12px 18px;padding:0;line-height:1.8;color:var(--text-secondary)">
${isWin ? `
<li><strong>${t('setup.envHintWsl')}</strong> — ${t('setup.envHintWslDesc')}</li>
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
` : ''}
${isMac ? `
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
<li><strong>${t('setup.envHintRemote')}</strong> — ${t('setup.envHintRemoteDesc')}</li>
` : ''}
${!isWin && !isMac ? `
<li><strong>${t('setup.envHintDocker')}</strong> — ${t('setup.envHintDockerDesc')}</li>
` : ''}
</ul>
${isWin ? `
<div class="setup-help-block">
<div class="setup-help-label">${t('setup.wslWebHint')}</div>
<div class="setup-help-copy">${t('setup.wslWebDesc')}</div>
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div class="setup-help-copy">${t('setup.domesticMirror')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
<div class="setup-help-copy">${t('setup.wslWebPostDeploy')}</div>
</div>
` : ''}
<div class="setup-help-block">
<div class="setup-help-label">${t('setup.dockerHint')}</div>
<div class="setup-help-copy">${t('setup.dockerDesc')}</div>
<code class="setup-help-code">npm i -g @qingchencloud/openclaw-zh</code>
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div class="setup-help-copy">${t('setup.domesticMirrorShort')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
</div>
<div class="setup-help-block">
<div class="setup-help-label">${t('setup.remoteHint')}</div>
<div class="setup-help-copy">${t('setup.remoteDesc')}</div>
<code class="setup-help-code">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
<div class="setup-help-copy">${t('setup.domesticMirrorShort')} <code>curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
</div>
</div>
</label>
<label class="setup-source-option" style="flex:1;cursor:pointer">
<input type="radio" name="install-source" value="official" style="margin-right:6px">
<div>
<div style="font-weight:600;font-size:var(--font-size-sm)">${t('setup.sourceOfficialLabel')}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
</div>
</label>
</details>
<div class="setup-inline-note">${t('setup.envHintLocalReinstall')}</div>
</div>
<div style="margin-bottom:var(--space-sm)" id="install-method-section">
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.installMethodLabel')}</label>
<select id="install-method" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
<option value="auto">${t('setup.methodAuto')}</option>
<option value="standalone-r2">${t('setup.methodStandaloneR2')}</option>
<option value="standalone-github">${t('setup.methodStandaloneGithub')}</option>
<option value="npm">${t('setup.methodNpm')}</option>
</select>
<div id="method-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px;line-height:1.5"></div>
</div>
<div style="margin-bottom:var(--space-sm)" id="registry-section">
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">${t('setup.registryLabel')}</label>
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
<option value="https://registry.npmmirror.com">${t('setup.registryTaobao')}</option>
<option value="https://registry.npmjs.org">${t('setup.registryNpm')}</option>
<option value="https://repo.huaweicloud.com/repository/npm/">${t('setup.registryHuawei')}</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="btn-install">${t('setup.installBtn')}</button>
${envHint}
`
}
@@ -462,6 +605,13 @@ function bindEvents(page, nodeOk, detectState) {
}
}).catch(() => {})
}
const searchPathsInput = page.querySelector('#input-openclaw-search-paths')
api.readPanelConfig().then(cfg => {
if (searchPathsInput) {
const values = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths : []
searchPathsInput.value = values.join('\n')
}
}).catch(() => {})
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
const value = dirInput?.value?.trim()
@@ -485,6 +635,39 @@ function bindEvents(page, nodeOk, detectState) {
}
})
page.querySelector('#btn-save-openclaw-search-paths')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-save-openclaw-search-paths')
const resultEl = page.querySelector('#openclaw-search-paths-result')
const paths = parseOpenclawSearchPaths(searchPathsInput?.value || '')
btn.disabled = true
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.saving')}</span>`
}
try {
const cfg = await api.readPanelConfig()
if (paths.length > 0) {
cfg.openclawSearchPaths = paths
} else {
delete cfg.openclawSearchPaths
}
await api.writePanelConfig(cfg)
invalidate()
if (resultEl) {
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared')}</span>`
}
toast(paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared'), 'success')
setTimeout(() => runDetect(page), 300)
} catch (e) {
if (resultEl) {
resultEl.innerHTML = `<span style="color:var(--error)">${t('setup.saveFailed', { err: e })}</span>`
}
toast(t('setup.saveFailed', { err: e }), 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-reset-openclaw-dir')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-reset-openclaw-dir')
btn.disabled = true
@@ -584,6 +767,105 @@ function bindEvents(page, nodeOk, detectState) {
}
})
const bindOpenclawCliPath = async (cliPath, btnEl, resultEl, successText = t('setup.searchOpenclawSelectSuccess'), originalText = btnEl?.textContent) => {
if (!cliPath) return false
if (btnEl) {
btnEl.disabled = true
btnEl.textContent = t('setup.searchOpenclawUsing')
}
try {
const cfg = await api.readPanelConfig()
cfg.openclawCliPath = cliPath
await api.writePanelConfig(cfg)
await api.invalidatePathCache().catch(() => {})
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--success)">✓ ${successText}</span>`
}
toast(successText, 'success')
setTimeout(() => runDetect(page), 300)
return true
} catch (e) {
if (btnEl) {
btnEl.disabled = false
btnEl.textContent = originalText || t('setup.scanUseBtn')
}
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.searchOpenclawSelectFailed', { err: e?.message || e })}</span>`
}
toast(t('setup.searchOpenclawSelectFailed', { err: e?.message || e }), 'error')
return false
}
}
page.querySelector('#btn-check-openclaw-path')?.addEventListener('click', async () => {
const input = page.querySelector('#input-openclaw-cli-path')
const resultEl = page.querySelector('#scan-openclaw-result')
const btn = page.querySelector('#btn-check-openclaw-path')
const cliPath = input?.value?.trim()
if (!cliPath) { toast(t('setup.enterPath'), 'warning'); return }
btn.disabled = true
btn.textContent = t('setup.detecting2')
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.detecting2')}</span>`
try {
const result = await api.checkOpenclawAtPath(cliPath)
if (result?.installed && result?.path) {
await bindOpenclawCliPath(result.path, btn, resultEl, t('setup.searchOpenclawManualSaved'), t('setup.searchOpenclawManualBtn'))
} else {
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.searchOpenclawManualNotFound')}</span>`
btn.disabled = false
btn.textContent = t('setup.searchOpenclawManualBtn')
}
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.scanFailed', { err: e })}</span>`
btn.disabled = false
btn.textContent = t('setup.searchOpenclawManualBtn')
}
})
page.querySelector('#btn-scan-openclaw')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-scan-openclaw')
const resultEl = page.querySelector('#scan-openclaw-result')
if (!btn || !resultEl) return
btn.disabled = true
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawScanning')}`
resultEl.style.display = 'block'
resultEl.innerHTML = `<span style="color:var(--text-tertiary)">${t('setup.searchOpenclawScanning')}</span>`
try {
const results = await api.scanOpenclawPaths()
if (!Array.isArray(results) || results.length === 0) {
resultEl.innerHTML = `<span style="color:var(--warning)">${t('setup.searchOpenclawEmpty')}</span>`
return
}
resultEl.innerHTML = `${results.map((item, index) => `
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
<span style="color:var(--success)">✓</span>
<div style="flex:1;min-width:0">
<code style="display:block;background:var(--bg-secondary);padding:2px 6px;border-radius:3px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(item.path)}">${escapeHtml(item.path)}</code>
<span style="font-size:11px;color:var(--text-tertiary)">${escapeHtml(openclawSourceLabel(item.source))}${item.version ? ` · v${escapeHtml(item.version)}` : ''}</span>
</div>
<button class="btn btn-primary btn-sm btn-use-openclaw-path" data-index="${index}" style="font-size:10px;padding:2px 8px">${t('setup.scanUseBtn')}</button>
</div>
`).join('')}
<div style="margin-top:6px;font-size:11px;color:var(--text-tertiary);line-height:1.6">${t('setup.searchOpenclawHint')}</div>`
resultEl.querySelectorAll('.btn-use-openclaw-path').forEach(btnEl => {
btnEl.addEventListener('click', async () => {
const item = results[Number(btnEl.dataset.index)]
if (!item?.path) return
await bindOpenclawCliPath(item.path, btnEl, resultEl)
})
})
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--danger)">${t('setup.scanFailed', { err: e })}</span>`
} finally {
btn.disabled = false
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawBtn')}`
}
})
// 安装方式联动:源切换时更新方式选项可见性
const methodSection = page.querySelector('#install-method-section')
const registrySection = page.querySelector('#registry-section')

View File

@@ -33,7 +33,8 @@ export function initRouter(contentEl) {
async function loadRoute() {
const hash = window.location.hash.slice(1) || _defaultRoute
const loader = routes[hash]
const routePath = hash.split('?')[0]
const loader = routes[routePath]
if (!loader || !_contentEl) return
// 竞态防护:记录本次加载 ID
@@ -49,7 +50,7 @@ async function loadRoute() {
_contentEl.innerHTML = ''
// 已缓存的模块:跳过 spinner直接渲染
let mod = _moduleCache[hash]
let mod = _moduleCache[routePath]
if (!mod) {
_contentEl.innerHTML = ''
// 仅首次加载显示 spinner
@@ -64,11 +65,11 @@ async function loadRoute() {
try {
mod = await retryLoad(loader, 3, 500)
} catch (e) {
console.error('[router] 模块加载失败:', hash, e)
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
console.error('[router] 模块加载失败:', routePath, e)
if (thisLoad === _loadId) showLoadError(_contentEl, routePath, e)
return
}
_moduleCache[hash] = mod
_moduleCache[routePath] = mod
} else {
_contentEl.innerHTML = ''
}
@@ -81,10 +82,10 @@ async function loadRoute() {
const renderFn = mod.render || mod.default
page = renderFn ? await withTimeout(renderFn(), 15000, '页面渲染超时') : mod
} catch (e) {
console.error('[router] 页面渲染失败:', hash, e)
console.error('[router] 页面渲染失败:', routePath, e)
// 渲染失败时清除缓存,下次重试时重新加载模块
delete _moduleCache[hash]
if (thisLoad === _loadId) showLoadError(_contentEl, hash, e)
delete _moduleCache[routePath]
if (thisLoad === _loadId) showLoadError(_contentEl, routePath, e)
return
}
if (thisLoad !== _loadId) return
@@ -102,7 +103,7 @@ async function loadRoute() {
// 更新侧边栏激活状态
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.route === hash)
item.classList.toggle('active', item.dataset.route === routePath)
})
}

View File

@@ -5,6 +5,13 @@
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.agent-card-header {
@@ -64,3 +71,268 @@
color: var(--text-secondary);
word-break: break-all;
}
.page-subhint {
margin-top: 6px;
font-size: var(--font-size-sm);
color: var(--accent);
}
/* ==================== Agent 详情页 ==================== */
.agent-back-link {
display: inline-block;
font-size: var(--font-size-sm);
color: var(--accent);
text-decoration: none;
margin-bottom: 4px;
transition: color var(--transition-fast);
}
.agent-back-link:hover {
color: var(--accent-hover);
}
.agent-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 16px;
}
.agent-section-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.agent-section-desc {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin: 0 0 16px 0;
}
.agent-section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.agent-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 600px) {
.agent-form-grid {
grid-template-columns: 1fr;
}
}
.agent-hint {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
padding: 8px 0;
}
.agent-save-bar {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* 文件卡片 */
.agent-file-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 14px 16px;
margin-bottom: 10px;
transition: border-color var(--transition-fast);
}
.agent-file-card:hover {
border-color: var(--border-focus);
}
.agent-file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.agent-file-info {
display: flex;
align-items: center;
gap: 10px;
}
.agent-file-name {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.agent-file-status {
font-size: var(--font-size-xs);
padding: 1px 8px;
border-radius: 10px;
}
.agent-file-status.file-exists {
background: var(--success-muted);
color: var(--success);
}
.agent-file-status.file-missing {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
.agent-file-desc {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-bottom: 2px;
}
.agent-file-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.agent-file-actions {
flex-shrink: 0;
}
/* 文件编辑器弹窗 */
.agent-file-editor-modal {
max-width: 800px !important;
width: 90vw;
}
.agent-file-editor {
width: 100%;
min-height: 400px;
max-height: 60vh;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.6;
padding: 12px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
resize: vertical;
tab-size: 2;
}
.agent-file-editor:focus {
outline: none;
border-color: var(--border-focus);
}
/* 渠道绑定卡片 */
.agent-binding-card {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 12px 16px;
margin-bottom: 8px;
}
.agent-binding-info {
display: flex;
align-items: center;
gap: 10px;
}
.agent-binding-channel {
font-weight: 500;
color: var(--text-primary);
}
.agent-binding-account {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
}
.agent-channels-section {
max-width: 700px;
}
.agent-files-section {
max-width: 700px;
}
.agent-overview {
max-width: 700px;
}
.agent-multiline-input {
min-height: 88px;
resize: vertical;
line-height: 1.6;
}
.agent-skills-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.agent-skill-card {
display: flex;
gap: 12px;
align-items: flex-start;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 14px;
cursor: pointer;
}
.agent-skill-card.is-muted {
opacity: 0.7;
}
.agent-skill-main {
flex: 1;
}
.agent-skill-head {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 4px;
}
.agent-skill-name {
font-weight: 600;
color: var(--text-primary);
}
.agent-skill-desc {
color: var(--text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
}
.agent-skill-badge {
font-size: var(--font-size-xs);
color: var(--warning);
background: var(--warning-muted);
padding: 2px 8px;
border-radius: 999px;
}
@media (max-width: 800px) {
.agent-skills-list {
grid-template-columns: 1fr;
}
}

View File

@@ -1212,6 +1212,270 @@
border-radius: 999px;
}
.setup-shell {
max-width: 1120px;
margin: 32px auto 48px;
}
.setup-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border-primary));
border-radius: calc(var(--radius-lg) + 4px);
}
.setup-hero-brand {
display: flex;
align-items: center;
gap: var(--space-lg);
min-width: 0;
}
.setup-hero-logo {
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 18px;
flex-shrink: 0;
}
.setup-hero-copy {
min-width: 0;
}
.setup-hero-title {
margin: 0 0 6px;
font-size: clamp(28px, 4vw, 36px);
line-height: 1.15;
}
.setup-hero-desc {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
max-width: 720px;
}
.setup-hero-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.setup-status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.setup-status-card {
display: flex;
align-items: flex-start;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
min-width: 0;
}
.setup-status-card.is-ok {
border-color: color-mix(in srgb, var(--success) 45%, var(--border-primary));
background: color-mix(in srgb, var(--success) 4%, var(--bg-card));
}
.setup-status-card.is-pending {
background: color-mix(in srgb, var(--warning, #f59e0b) 4%, var(--bg-card));
}
.setup-status-icon {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: 700;
flex-shrink: 0;
}
.setup-status-card.is-ok .setup-status-icon {
background: color-mix(in srgb, var(--success) 16%, var(--bg-tertiary));
color: var(--success);
}
.setup-status-card.is-pending .setup-status-icon {
background: color-mix(in srgb, var(--warning, #f59e0b) 16%, var(--bg-tertiary));
color: var(--warning, #f59e0b);
}
.setup-status-body {
min-width: 0;
}
.setup-status-title {
font-size: var(--font-size-sm);
font-weight: 700;
color: var(--text-primary);
}
.setup-status-meta {
margin-top: 4px;
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: 1.5;
}
.setup-main-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: var(--space-lg);
align-items: start;
}
.setup-column {
min-width: 0;
}
.setup-search-panel {
margin-top: var(--space-lg);
padding: var(--space-lg);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border-primary));
border-radius: var(--radius-lg);
}
.setup-install-panel {
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.setup-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.setup-inline-note {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: 1.6;
}
.setup-help-details {
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.setup-help-details summary {
cursor: pointer;
list-style: none;
padding: 12px 14px;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.setup-help-details summary::-webkit-details-marker {
display: none;
}
.setup-help-content {
padding: 0 14px 14px;
}
.setup-help-block + .setup-help-block {
margin-top: 12px;
}
.setup-help-label {
font-size: var(--font-size-xs);
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.setup-help-copy {
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: 1.7;
margin-top: 4px;
}
.setup-help-code {
display: block;
margin-top: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border-primary);
font-size: 11px;
line-height: 1.6;
user-select: all;
word-break: break-all;
}
@media (max-width: 960px) {
.setup-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.setup-main-grid {
grid-template-columns: 1fr;
}
.setup-hero {
flex-direction: column;
align-items: flex-start;
}
.setup-hero-actions {
width: 100%;
}
}
@media (max-width: 640px) {
.setup-shell {
margin-top: 12px;
}
.setup-hero {
padding: var(--space-lg);
}
.setup-hero-brand {
flex-direction: column;
align-items: flex-start;
}
.setup-status-grid {
grid-template-columns: 1fr;
}
.setup-input-row {
flex-direction: column;
align-items: stretch;
}
}
/* 表单开关 */
.toggle-switch {
position: relative;