feat: AI助手支持 Anthropic/Gemini 原生API + 修复Windows终端闪烁

- AI助手新增 API 类型选择器(OpenAI兼容 / Anthropic原生 / Google Gemini)
- 实现 Anthropic Messages API 流式调用 + 工具调用(tool_use/tool_result)
- 实现 Google Gemini streamGenerateContent + 工具调用(functionCall)
- 设置弹窗动态切换 placeholder 和提示文本
- 测试按钮和模型拉取适配三种 API 类型
- 修复 Windows 上 Gateway 状态轮询导致终端反复闪烁(execSync/spawn 加 windowsHide)
- 默认密码统一为 123456 + 改密码后自动移除顶部横幅
- 后端 API 增加暴力破解保护、配置缓存、请求体大小限制
This commit is contained in:
晴天
2026-03-06 22:46:40 +08:00
parent 80197bdc60
commit 921c371934
23 changed files with 2017 additions and 238 deletions

View File

@@ -11,16 +11,17 @@ git status
2. 确认 CI 全部通过main 分支绿灯)
3. 更新 `CHANGELOG.md`,在顶部加入本次版本的变更记录
4. 更新 `src-tauri/tauri.conf.json` 中的 `version` 字段,与即将发布的版本号保持一致:
```json
{ "version": "1.2.3" }
3. 更新版本号(一条命令自动同步 package.json → tauri.conf.json → Cargo.toml → docs/index.html
// turbo
```bash
npm run version:set 1.2.3
```
4. 更新 `CHANGELOG.md`,在顶部加入本次版本的变更记录
5. 提交版本更新:
```bash
git add CHANGELOG.md src-tauri/tauri.conf.json
git add -A
git commit -m "chore: release v1.2.3"
git push origin main
```

View File

@@ -1,12 +1,37 @@
# 贡献指南
# 贡献指南 & 维护手册
感谢你对 ClawPanel 项目的关注!以下是参与贡献的相关说明
感谢你对 ClawPanel 项目的关注!本文档同时作为**贡献指南**和**项目维护手册**,涵盖开发、构建、发版、部署的完整工作流
> 🌐 **官网**: [claw.qt.cool](https://claw.qt.cool/) | 📦 **仓库**: [github.com/qingchencloud/clawpanel](https://github.com/qingchencloud/clawpanel)
---
## 目录
- [开发环境要求](#开发环境要求)
- [项目结构](#项目结构)
- [运行模式](#运行模式)
- [版本管理](#版本管理)
- [发版流程](#发版流程)
- [CI/CD 工作流](#cicd-工作流)
- [配置文件说明](#配置文件说明)
- [关键脚本](#关键脚本)
- [前端开发约定](#前端开发约定)
- [Rust 后端约定](#rust-后端约定)
- [安全机制](#安全机制)
- [部署模式](#部署模式)
- [分支与提交规范](#分支与提交规范)
- [PR 流程](#pr-流程)
- [代码规范](#代码规范)
- [问题反馈](#问题反馈)
---
## 开发环境要求
| 依赖 | 最低版本 | 说明 |
|------|----------|------|
| Node.js | 18+ | 前端构建 |
| Node.js | 18+ | 前端构建(推荐 22 LTS |
| Rust | stable | Tauri 后端编译 |
| Tauri CLI | v2 | `cargo install tauri-cli --version "^2"` |
@@ -24,45 +49,286 @@ npm install
#### macOS / Linux
```bash
# 启动开发模式(完整 Tauri 桌面应用
# 启动完整 Tauri 桌面应用
./scripts/dev.sh
# 仅启动前端(浏览器调试,使用 mock 数据
# 仅启动前端(浏览器调试,含 dev-api 真实后端
./scripts/dev.sh web
```
#### Windows
```powershell
# 启动开发模式(完整 Tauri 桌面应用
# 启动完整 Tauri 桌面应用
npm run tauri dev
# 仅启动前端(浏览器调试,使用 mock 数据
# 仅启动前端(浏览器调试,含 dev-api 真实后端
npm run dev
```
> Windows 开发需要安装 [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)(勾选「使用 C++ 的桌面开发」工作负载)和 [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)Win10+ 通常已预装)。
---
## 项目结构
```
clawpanel/
├── src/ # 前端源码Vanilla JS
│ ├── pages/ # 页面模块(每个页面导出 render 函数
│ ├── components/ # 通用组件侧边栏、弹窗、Toast
│ ├── lib/ # 工具库Tauri API 封装、主题切换)
│ ├── style/ # CSS 样式CSS Variables 驱动)
│ ├── router.js # Hash 路由
└── main.js # 入口文件
├── src-tauri/ # Rust 后端
│ ├── src/commands/ # Tauri 命令(按功能模块拆分)
│ ├── Cargo.toml # Rust 依赖
└── tauri.conf.json # Tauri 配置
├── public/ # 静态资源(图片、图标)
└── .github/workflows/ # CI/CD 工作流
├── src/ # 前端源码Vanilla JS + Vite
│ ├── pages/ # 页面模块(每个导出 render()
│ ├── dashboard.js # 仪表盘
│ ├── assistant.js # AI 助手
│ ├── chat.js # 实时聊天
│ ├── chat-debug.js # 聊天调试WebSocket
│ ├── services.js # 服务管理
│ │ ├── logs.js # 日志查看
│ ├── config.js # 模型配置
│ ├── gateway.js # 网关配置
│ ├── agents.js # Agent 管理
├── memory.js # 记忆管理
│ │ ├── extensions.js # 扩展工具
│ │ ├── security.js # 安全设置
│ │ ├── setup.js # 初始设置向导
│ │ └── about.js # 关于页面
│ ├── components/ # 通用组件
│ │ ├── sidebar.js # 侧边导航栏
│ │ ├── toast.js # 消息提示
│ │ └── modal.js # 弹窗组件
│ ├── lib/ # 工具库
│ │ ├── tauri-api.js # Tauri API 封装(含 Web fallback + mock
│ │ ├── theme.js # 主题切换(暗色/亮色)
│ │ └── app-state.js # 应用状态管理
│ ├── style/ # CSS 样式CSS Variables 驱动)
│ │ ├── variables.css # CSS 变量定义(主题色、间距、字号)
│ │ ├── base.css # 基础样式重置
│ │ ├── layout.css # 布局(侧边栏、内容区)
│ │ ├── components.css # 组件样式(按钮、表单、卡片)
│ │ ├── pages.css # 页面通用样式
│ │ ├── chat.css # 聊天页样式
│ │ ├── agents.css # Agent 页样式
│ │ ├── debug.css # 调试页样式
│ │ └── assistant.css # AI 助手页样式
│ ├── router.js # Hash 路由
│ └── main.js # 入口文件(含密码保护逻辑)
├── src-tauri/ # Rust 后端Tauri v2
│ ├── src/
│ │ ├── lib.rs # 入口 + 命令注册
│ │ ├── commands/ # Tauri 命令(按功能模块拆分)
│ │ │ ├── mod.rs # 模块注册 + enhanced_path()
│ │ │ ├── config.rs # 配置读写 + 版本管理 + 面板配置
│ │ │ ├── service.rs # Gateway 服务管理(跨平台)
│ │ │ ├── agent.rs # Agent CRUD
│ │ │ ├── memory.rs # 记忆文件管理
│ │ │ ├── logs.rs # 日志读取/搜索
│ │ │ ├── device.rs # 设备密钥 + Gateway 握手
│ │ │ ├── pairing.rs # 设备配对
│ │ │ ├── extensions.rs # 扩展工具cftunnel / clawapp
│ │ │ └── assistant.rs # AI 助手工具调用
│ │ ├── models/ # 数据模型
│ │ ├── tray.rs # 系统托盘
│ │ └── utils.rs # 工具函数
│ ├── Cargo.toml # Rust 依赖 + 版本号
│ └── tauri.conf.json # Tauri 配置 + 版本号
├── scripts/ # 开发与运维脚本
│ ├── dev.sh # macOS/Linux 开发启动
│ ├── dev-api.js # Vite 插件Web 模式真实后端 API
│ ├── build.sh # macOS/Linux 编译与打包
│ ├── linux-deploy.sh # Linux 服务器一键部署
│ └── sync-version.js # 版本号同步脚本
├── docs/ # 文档与截图
│ ├── index.html # 官网claw.qt.cool
│ ├── linux-deploy.md # Linux 部署指南
│ └── docker-deploy.md # Docker 部署指南
├── public/ # 静态资源图标、Logo
├── .github/workflows/ # CI/CD
│ ├── ci.yml # 持续集成push/PR → 检查)
│ └── release.yml # 发布构建tag → 全平台打包)
├── .windsurf/workflows/ # Cascade AI 工作流
│ └── release.md # 发版工作流指令
├── package.json # 前端依赖 + 版本号(唯一真相源)
├── vite.config.js # Vite 配置
├── CHANGELOG.md # 更新日志
├── CONTRIBUTING.md # 本文件
├── SECURITY.md # 安全政策
└── README.md # 项目介绍
```
### 前端页面开发约定
---
## 运行模式
ClawPanel 有两种运行模式,前端代码通过 `isTauri` 标志自动适配:
| 模式 | 启动方式 | 后端 | API 通信 | 适用场景 |
|------|----------|------|----------|----------|
| **Tauri 桌面** | `npm run tauri dev` | Rust (IPC) | `window.__TAURI_INTERNALS__``invoke()` | macOS / Windows / Linux 桌面 |
| **Web 浏览器** | `npm run dev` | Node.js (`dev-api.js`) | `fetch('/__api/xxx')` | Linux 服务器远程管理 |
### API 调用链路
```
前端代码
tauri-api.js → isTauri?
├─ YES → invoke() → Rust IPC → src-tauri/src/commands/*.rs
└─ NO → webInvoke() → fetch('/__api/cmd') → scripts/dev-api.js
↓ 失败时
mockInvoke() → 内置 mock 数据(仅用于无后端调试)
```
### Web 模式后端 (`dev-api.js`)
`scripts/dev-api.js` 是一个 Vite 插件,在 Web 模式下提供与 Tauri IPC 等效的 HTTP API。它
- 拦截 `/__api/*` 请求
- 调用与 Rust 命令同名的 handler 函数
- 提供密码保护中间件session + cookie
- 实际执行 `openclaw` CLI 命令操作服务器
---
## 版本管理
### 版本号位置
版本号以 `package.json` 为**唯一真相源**,通过同步脚本分发到其他文件:
| 文件 | 字段 | 用途 |
|------|------|------|
| `package.json` | `version` | **主版本源** — npm、前端构建、侧边栏显示 |
| `src-tauri/tauri.conf.json` | `version` | Tauri 打包版本号 |
| `src-tauri/Cargo.toml` | `version` | Rust crate 版本号 |
| `docs/index.html` | `softwareVersion` | 官网 JSON-LD SEO |
| `CHANGELOG.md` | `## [x.y.z]` | 变更日志(需手动编写内容) |
### 同步命令
```bash
# 设置新版本并自动同步到所有文件
npm run version:set 0.6.0
# 仅同步当前 package.json 版本到其他文件
npm run version:sync
```
### 前端版本读取
侧边栏底部自动从 `package.json` 读取版本号,无需手动维护:
```javascript
import { version as APP_VERSION } from '../../package.json'
```
---
## 发版流程
### 完整步骤
```bash
# 1. 确认工作区干净
git status
# 2. 设置新版本号(自动同步到 tauri.conf.json / Cargo.toml / docs/index.html
npm run version:set 0.6.0
# 3. 编写 CHANGELOG.md 变更记录
# 4. 提交
git add -A
git commit -m "chore: release v0.6.0"
git push origin main
# 5. 打 tag 触发自动构建
git tag v0.6.0
git push origin v0.6.0
```
### 发版后自动执行
推送 tag 后GitHub Actions (`release.yml`) 会自动:
1. **并行构建** macOS ARM64 / macOS Intel / Linux / Windows 四个平台
2. **创建 GitHub Release** 并上传安装包(.dmg / .exe / .msi / .AppImage / .deb / .rpm
3. 所有平台构建完成后,**自动生成 Release Notes**(含下载表格 + 分类 Changelog
### 回滚
```bash
git tag -d v0.6.0
git push origin :refs/tags/v0.6.0
# 修复后重新打 tag
```
---
## CI/CD 工作流
### `ci.yml` — 持续集成
- **触发**push 到 `main` 分支 或 PR 到 `main`
- **平台**macOS / Linux / Windows 三平台并行
- **检查项**
1. `npm ci` — 前端依赖安装
2. `cargo fmt --check` — Rust 代码格式
3. `cargo check` — Rust 编译检查
4. `cargo clippy -- -D warnings` — Rust lint警告即失败
5. `npm run build` — 前端构建验证
### `release.yml` — 发布构建
- **触发**:推送 `v*` 标签 或 手动触发
- **平台**macOS ARM64 / macOS Intel / Linux x64 / Windows x64
- **产物**:通过 `tauri-apps/tauri-action@v0` 构建并上传到 GitHub Release
- **Release Notes**:独立 job等所有平台构建完成后统一生成
---
## 配置文件说明
### `~/.openclaw/openclaw.json`
OpenClaw 主配置文件,包含模型配置、网关配置等。由 ClawPanel 的"模型配置"和"网关配置"页面读写。
### `~/.openclaw/clawpanel.json`
ClawPanel 面板自身的配置文件,独立于 OpenClaw
```json
{
"accessPassword": "用户设置的访问密码",
"mustChangePassword": true,
"ignoreRisk": false,
"nodePath": "/custom/node/path"
}
```
| 字段 | 说明 |
|------|------|
| `accessPassword` | 面板访问密码(明文存储,桌面端本地比对) |
| `mustChangePassword` | `true` = 首次登录后强制修改默认密码 |
| `ignoreRisk` | `true` = 无视风险模式,跳过密码保护 |
| `nodePath` | 用户自定义 Node.js 路径,补充到 PATH |
### `~/.openclaw/npm-registry.txt`
用户配置的 npm 源地址,默认 `https://registry.npmmirror.com`
---
## 关键脚本
| 脚本 | 用途 |
|------|------|
| `scripts/dev.sh` | macOS/Linux 开发启动(清理旧进程 → 启动 Vite 或 Tauri |
| `scripts/dev-api.js` | Vite 插件Web 模式的 Node.js 后端API + 认证中间件) |
| `scripts/build.sh` | macOS/Linux 构建脚本(支持 `check` / `release` 模式) |
| `scripts/linux-deploy.sh` | Linux 服务器一键部署(安装依赖 → 克隆仓库 → systemd 服务) |
| `scripts/sync-version.js` | 版本号同步(`package.json` → 其他 4 个文件) |
---
## 前端开发约定
### 页面模块
每个页面是一个独立 JS 模块,导出 `render()` 函数:
@@ -78,9 +344,58 @@ export async function render() {
}
```
关键原则:`render()` 必须立即返回 DOM 元素,不要 `await` 数据加载,否则会阻塞页面切换。
**关键原则**`render()` 必须立即返回 DOM 元素,不要 `await` 数据加载,否则会阻塞页面切换。
### Rust 跨平台开发约定
### 新增页面清单
1. 创建 `src/pages/xxx.js`,导出 `render()`
2.`src/main.js` 注册路由:`registerRoute('/xxx', () => import('./pages/xxx.js'))`
3.`src/components/sidebar.js``NAV_ITEMS_FULL` 中添加导航项
4.`ICONS` 对象中添加对应图标 SVG
### API 调用
统一通过 `tauri-api.js` 封装,不要在页面中直接 `fetch`
```javascript
import { api } from '../lib/tauri-api.js'
// 读(自带缓存)
const config = await api.readOpenclawConfig()
// 写(自动清缓存)
await api.writeOpenclawConfig(config)
```
### 双模式适配
页面中需要区分 Tauri/Web 行为时:
```javascript
const isTauri = !!window.__TAURI_INTERNALS__
if (isTauri) {
// 桌面端:通过 Tauri IPC
const { api } = await import('../lib/tauri-api.js')
const cfg = await api.readPanelConfig()
} else {
// Web 端:通过 HTTP API
const resp = await fetch('/__api/xxx', { method: 'POST', ... })
}
```
---
## Rust 后端约定
### 新增 Tauri 命令
1. 在对应的 `src-tauri/src/commands/xxx.rs` 中添加 `#[tauri::command]` 函数
2.`src-tauri/src/lib.rs``invoke_handler` 中注册
3.`src/lib/tauri-api.js``api` 对象中添加前端包装方法
4.`mockInvoke``mocks` 对象中添加 mock 数据(供无后端调试)
### 跨平台代码
平台相关代码使用条件编译:
@@ -89,48 +404,103 @@ export async function render() {
{
// macOS: launchctl / plist
}
#[cfg(target_os = "linux")]
{
// Linux: 进程管理 / systemd
}
#[cfg(target_os = "windows")]
{
// Windows: openclaw CLI / tasklist
}
```
## 分支策略
### PATH 问题
Tauri 桌面应用启动时 PATH 可能不完整macOS Finder 启动、Windows 非默认安装路径)。所有需要调用外部命令的地方必须使用 `super::enhanced_path()` 设置环境变量。
---
## 安全机制
### 密码保护
ClawPanel 支持访问密码保护,**Web 模式和 Tauri 桌面端均可启用**
| 模式 | 密码存储 | 验证方式 | 会话管理 |
|------|----------|----------|----------|
| Web | `clawpanel.json` | 后端比对 + HTTP-only Cookie | 服务端 session24h TTL |
| Tauri 桌面 | `clawpanel.json` | 前端本地比对 | `sessionStorage` |
### 密码保护流程
```
启动 → 读 clawpanel.json
├─ 无密码 + ignoreRisk → 放行
├─ 有密码 + 未认证 → 弹出登录覆盖层
└─ 有密码 + mustChangePassword → 登录后强制改密码
```
### 安全设置页 (`/security`)
- 查看当前密码状态
- 修改密码含强度校验≥6 位、不能纯数字、不能常见弱密码)
- 无视风险模式(关闭密码保护,仅建议受信任内网)
---
## 部署模式
### 1. 桌面应用Tauri
面向 macOS / Windows / Linux 桌面用户,从 [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases) 下载安装包。
### 2. Linux 服务器Web 版)
一键部署脚本,适用于无桌面环境的 Linux 服务器:
```bash
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/scripts/linux-deploy.sh | bash
```
部署后通过 `http://服务器IP:1420` 访问,自动生成默认密码。
详见 [Linux 部署指南](docs/linux-deploy.md)。
### 3. Docker
详见 [Docker 部署指南](docs/docker-deploy.md)。
---
## 分支与提交规范
### 分支策略
- 所有开发基于 `main` 分支
- 新功能分支:`feature/功能描述`(例如 `feature/log-export`
- 修复分支:`fix/问题描述`(例如 `fix/model-save-crash`
- 完成后发起 PR 合并回 `main`
## 提交规范
### 提交格式
提交信息采用 [Conventional Commits](https://www.conventionalcommits.org/) 格式
采用 [Conventional Commits](https://www.conventionalcommits.org/)
```
<类型>(可选范围): 简要描述
```
### 类型说明
| 类型 | 说明 | 示例 |
|------|------|------|
| `feat` | 新功能 | `feat(model): 新增模型批量测试功能` |
| `fix` | 修复 Bug | `fix(gateway): 修复端口配置未生效的问题` |
| `docs` | 文档变更 | `docs: 更新安装说明` |
| `style` | 代码格式 | `style(css): 统一按钮圆角` |
| `refactor` | 重构 | `refactor(router): 简化路由匹配逻辑` |
| `perf` | 性能优化 | `perf(router): 添加模块缓存避免重复加载` |
| `chore` | 构建/工具 | `chore: release v0.6.0` |
| `security` | 安全修复 | `security(api): 修复命令注入漏洞` |
| 类型 | 说明 |
|------|------|
| `feat` | 新功能 |
| `fix` | 修复 Bug |
| `docs` | 文档变更 |
| `style` | 代码格式调整(不影响逻辑) |
| `refactor` | 重构(非新功能、非 Bug 修复) |
| `perf` | 性能优化 |
| `test` | 测试相关 |
| `chore` | 构建/工具/依赖变更 |
### 示例
```
feat(model): 新增模型批量测试功能
fix(gateway): 修复端口配置未生效的问题
perf(router): 添加模块缓存避免重复加载
docs: 更新安装说明
```
---
## PR 流程
@@ -142,14 +512,19 @@ docs: 更新安装说明
6. 发起 Pull Request描述清楚变更内容和测试情况
7. 等待代码审查,根据反馈修改
---
## 代码规范
- **前端**:使用 Vanilla JS不引入第三方框架
- **注释**:所有代码注释使用中文
- **风格**:简洁清晰,避免过度封装
- **命名**:变量和函数使用驼峰命名(camelCaseCSS 类名使用短横线命名(kebab-case
- **命名**:变量和函数使用 camelCaseCSS 类名使用 kebab-case
- **资源**:静态资源本地化,禁止引用远程 CDN
- **异步**:页面 render() 中禁止阻塞式 await数据加载走后台异步
- **异步**:页面 `render()` 中禁止阻塞式 await数据加载走后台异步
- **版本**:只改 `package.json`,运行 `npm run version:sync` 同步
---
## 问题反馈

View File

@@ -4,8 +4,9 @@
| 版本 | 支持状态 |
|------|----------|
| 0.3.x | ✅ 安全更新 |
| < 0.3 | ❌ 不再维护 |
| 0.5.x | ✅ 安全更新 |
| 0.4.x | ⚠️ 仅关键修复 |
| < 0.4 | ❌ 不再维护 |
## 报告安全漏洞

View File

@@ -34,7 +34,7 @@
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.5.6",
"softwareVersion": "0.5.7",
"author": {
"@type": "Organization",
"name": "晴辰云 QingchenCloud",

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.5.6",
"version": "0.5.7",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",
@@ -25,7 +25,9 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"version:sync": "node scripts/sync-version.js",
"version:set": "node scripts/sync-version.js"
},
"dependencies": {
"@tauri-apps/api": "^2.5.0",

View File

@@ -21,15 +21,123 @@ const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write']
const PANEL_CONFIG_PATH = path.join(OPENCLAW_DIR, 'clawpanel.json')
// === 访问密码 & Session 管理 ===
const _sessions = new Map() // token → { expires }
const SESSION_TTL = 24 * 60 * 60 * 1000 // 24h
const AUTH_EXEMPT = new Set(['auth_check', 'auth_login', 'auth_logout'])
// 登录限速防暴力破解IP 级别5次失败后锁定60秒
const _loginAttempts = new Map() // ip → { count, lockedUntil }
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION = 60 * 1000 // 60s
function checkLoginRateLimit(ip) {
const now = Date.now()
const record = _loginAttempts.get(ip)
if (!record) return null
if (record.lockedUntil && now < record.lockedUntil) {
const remaining = Math.ceil((record.lockedUntil - now) / 1000)
return `登录失败次数过多,请 ${remaining} 秒后再试`
}
if (record.lockedUntil && now >= record.lockedUntil) {
_loginAttempts.delete(ip)
}
return null
}
function recordLoginFailure(ip) {
const record = _loginAttempts.get(ip) || { count: 0, lockedUntil: null }
record.count++
if (record.count >= MAX_LOGIN_ATTEMPTS) {
record.lockedUntil = Date.now() + LOCKOUT_DURATION
record.count = 0
}
_loginAttempts.set(ip, record)
}
function clearLoginAttempts(ip) {
_loginAttempts.delete(ip)
}
// 配置缓存避免每次请求同步读磁盘TTL 2秒写入时立即失效
let _panelConfigCache = null
let _panelConfigCacheTime = 0
const CONFIG_CACHE_TTL = 2000 // 2s
function readPanelConfig() {
const now = Date.now()
if (_panelConfigCache && (now - _panelConfigCacheTime) < CONFIG_CACHE_TTL) {
return JSON.parse(JSON.stringify(_panelConfigCache))
}
try {
if (fs.existsSync(PANEL_CONFIG_PATH)) {
_panelConfigCache = JSON.parse(fs.readFileSync(PANEL_CONFIG_PATH, 'utf8'))
_panelConfigCacheTime = now
return JSON.parse(JSON.stringify(_panelConfigCache))
}
} catch {}
return {}
}
function invalidateConfigCache() {
_panelConfigCache = null
_panelConfigCacheTime = 0
}
function getAccessPassword() {
return readPanelConfig().accessPassword || ''
}
function parseCookies(req) {
const obj = {}
;(req.headers.cookie || '').split(';').forEach(pair => {
const [k, ...v] = pair.trim().split('=')
if (k) obj[k] = decodeURIComponent(v.join('='))
})
return obj
}
function isAuthenticated(req) {
const pw = getAccessPassword()
if (!pw) return true // 未设密码,放行
const cookies = parseCookies(req)
const token = cookies.clawpanel_session
if (!token) return false
const session = _sessions.get(token)
if (!session || Date.now() > session.expires) {
_sessions.delete(token)
return false
}
return true
}
function checkPasswordStrength(pw) {
if (!pw || pw.length < 6) return '密码至少 6 位'
if (pw.length > 64) return '密码不能超过 64 位'
if (/^\d+$/.test(pw)) return '密码不能是纯数字'
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码'
return null // 通过
}
function isUnsafePath(p) {
return !p || p.includes('..') || p.includes('\0') || path.isAbsolute(p)
}
const MAX_BODY_SIZE = 1024 * 1024 // 1MB
function readBody(req) {
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => body += chunk)
let size = 0
req.on('data', chunk => {
size += chunk.length
if (size > MAX_BODY_SIZE) { req.destroy(); resolve({}); return }
body += chunk
})
req.on('end', () => {
try { resolve(JSON.parse(body || '{}')) }
catch { resolve({}) }
@@ -201,6 +309,7 @@ function winStartGateway() {
detached: true,
stdio: ['ignore', out, err],
shell: true,
windowsHide: true,
cwd: homedir(),
})
child.unref()
@@ -210,7 +319,7 @@ function winStopGateway() {
const { running, pid } = winCheckGateway()
if (!running || !pid) throw new Error('Gateway 未运行')
try {
execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000 })
execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true })
} catch (e) {
throw new Error('停止失败: ' + (e.message || e))
}
@@ -220,7 +329,7 @@ function winCheckGateway() {
const port = readGatewayPort()
try {
// 用 netstat 精确查找监听指定端口的进程 PID
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000 }).toString().trim()
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000, windowsHide: true }).toString().trim()
if (!out) return { running: false, pid: null }
// 提取 PID最后一列
const parts = out.split('\n')[0].trim().split(/\s+/)
@@ -228,7 +337,7 @@ function winCheckGateway() {
if (!pid) return { running: false, pid: null }
// 验证进程是否为 node/openclaw排除其他程序碰巧占用同端口
try {
const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000 }).toString().trim()
const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000, windowsHide: true }).toString().trim()
const isGateway = /node|openclaw/i.test(taskOut)
return { running: isGateway, pid: isGateway ? pid : null }
} catch {
@@ -483,7 +592,7 @@ const handlers = {
check_node() {
try {
const ver = execSync('node --version 2>&1').toString().trim()
const ver = execSync('node --version 2>&1', { windowsHide: true }).toString().trim()
return { installed: true, version: ver }
} catch {
return { installed: false, version: null }
@@ -501,7 +610,7 @@ const handlers = {
} catch {}
}
if (!current) {
try { current = execSync('openclaw --version 2>&1').toString().trim().split(/\s+/).pop() } catch {}
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
}
return { current, latest: null, update_available: false, source: 'chinese' }
},
@@ -573,7 +682,7 @@ const handlers = {
const logPath = path.join(LOGS_DIR, file)
if (!fs.existsSync(logPath)) return ''
try {
return execSync(`tail -${lines} "${logPath}" 2>&1`).toString()
return execSync(`tail -${lines} "${logPath}" 2>&1`, { windowsHide: true }).toString()
} catch {
const content = fs.readFileSync(logPath, 'utf8')
return content.split('\n').slice(-lines).join('\n')
@@ -714,8 +823,8 @@ const handlers = {
// Gateway 安装/卸载
install_gateway() {
try { execSync('openclaw --version 2>&1') } catch { throw new Error('openclaw CLI 未安装') }
return execSync('openclaw gateway install 2>&1').toString() || 'Gateway 服务已安装'
try { execSync('openclaw --version 2>&1', { windowsHide: true }) } catch { throw new Error('openclaw CLI 未安装') }
return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装'
},
upgrade_openclaw({ source = 'chinese' } = {}) {
@@ -723,7 +832,7 @@ const handlers = {
const pkg = source === 'official' ? '@anthropic-ai/claw' : '@qingchencloud/openclaw-zh'
const npmBin = isWindows ? 'npm.cmd' : 'npm'
try {
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000 }).toString()
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
return `升级完成 (${source})\n${out.slice(-200)}`
} catch (e) {
throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300))
@@ -931,6 +1040,22 @@ const handlers = {
return null
},
// === 访问密码认证 ===
auth_check() {
const pw = getAccessPassword()
return { required: !!pw, authenticated: false /* 由中间件覆写 */ }
},
auth_login() { throw new Error('由中间件处理') },
auth_logout() { throw new Error('由中间件处理') },
auth_set_password({ password }) {
const cfg = readPanelConfig()
cfg.accessPassword = password || ''
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
// 清除所有 session密码变更后强制重新登录
_sessions.clear()
return true
},
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
write_env_file({ path: p, config }) {
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
@@ -944,37 +1069,233 @@ const handlers = {
// === Vite 插件 ===
// 初始化:密码检测 + 启动日志 + 定时清理
function _initApi() {
const cfg = readPanelConfig()
if (!cfg.accessPassword && !cfg.ignoreRisk) {
cfg.accessPassword = '123456'
cfg.mustChangePassword = true
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
console.log('[api] ⚠️ 首次启动,默认访问密码: 123456')
console.log('[api] ⚠️ 首次登录后将强制要求修改密码')
}
const pw = getAccessPassword()
console.log('[api] API 已启动,配置目录:', OPENCLAW_DIR)
console.log('[api] 平台:', isMac ? 'macOS' : process.platform)
console.log('[api] 访问密码:', pw ? '已设置' : (cfg.ignoreRisk ? '无视风险模式(无密码)' : '未设置'))
// 定时清理过期 session 和登录限速记录(每 10 分钟)
setInterval(() => {
const now = Date.now()
for (const [token, session] of _sessions) {
if (now > session.expires) _sessions.delete(token)
}
for (const [ip, record] of _loginAttempts) {
if (record.lockedUntil && now >= record.lockedUntil) _loginAttempts.delete(ip)
}
}, 10 * 60 * 1000)
}
// API 中间件dev server 和 preview server 共用)
async function _apiMiddleware(req, res, next) {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = req.url.slice(7).split('?')[0]
// --- 认证特殊处理 ---
if (cmd === 'auth_check') {
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
const isDefault = pw === '123456'
const resp = {
required: !!pw,
authenticated: !pw || isAuthenticated(req),
mustChangePassword: isDefault,
}
if (isDefault) resp.defaultPassword = '123456'
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(resp))
return
}
if (cmd === 'auth_login') {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || ''
const rateLimitErr = checkLoginRateLimit(clientIp)
if (rateLimitErr) {
res.statusCode = 429
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: rateLimitErr }))
return
}
const args = await readBody(req)
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
if (!pw) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (args.password !== pw) {
recordLoginFailure(clientIp)
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '密码错误' }))
return
}
clearLoginAttempts(clientIp)
const token = crypto.randomUUID()
_sessions.set(token, { expires: Date.now() + SESSION_TTL })
res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true, mustChangePassword: !!cfg.mustChangePassword }))
return
}
if (cmd === 'auth_change_password') {
const args = await readBody(req)
const cfg = readPanelConfig()
const pw = cfg.accessPassword || ''
if (pw && !isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
if (pw && args.oldPassword !== pw) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '当前密码错误' }))
return
}
const weakErr = checkPasswordStrength(args.newPassword)
if (weakErr) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: weakErr }))
return
}
if (args.newPassword === pw) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '新密码不能与旧密码相同' }))
return
}
cfg.accessPassword = args.newPassword
delete cfg.mustChangePassword
delete cfg.ignoreRisk
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
_sessions.clear()
const token = crypto.randomUUID()
_sessions.set(token, { expires: Date.now() + SESSION_TTL })
res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (cmd === 'auth_status') {
const cfg = readPanelConfig()
if (cfg.accessPassword && !isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
const isDefault = cfg.accessPassword === '123456'
const result = {
hasPassword: !!cfg.accessPassword,
mustChangePassword: isDefault,
ignoreRisk: !!cfg.ignoreRisk,
}
if (isDefault) {
result.defaultPassword = '123456'
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
return
}
if (cmd === 'auth_ignore_risk') {
if (!isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录' }))
return
}
const args = await readBody(req)
const cfg = readPanelConfig()
if (args.enable) {
delete cfg.accessPassword
delete cfg.mustChangePassword
cfg.ignoreRisk = true
_sessions.clear()
} else {
delete cfg.ignoreRisk
}
fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2))
invalidateConfigCache()
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
if (cmd === 'auth_logout') {
const cookies = parseCookies(req)
if (cookies.clawpanel_session) _sessions.delete(cookies.clawpanel_session)
res.setHeader('Set-Cookie', 'clawpanel_session=; Path=/; HttpOnly; Max-Age=0')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
return
}
// --- 认证中间件:非豁免接口必须校验 ---
if (!isAuthenticated(req)) {
res.statusCode = 401
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: '未登录', code: 'AUTH_REQUIRED' }))
return
}
const handler = handlers[cmd]
if (!handler) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` }))
return
}
try {
const args = await readBody(req)
const result = await handler(args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: e.message || String(e) }))
}
}
export function devApiPlugin() {
let _inited = false
function ensureInit() {
if (_inited) return
_inited = true
_initApi()
}
return {
name: 'clawpanel-dev-api',
configureServer(server) {
console.log('[dev-api] 开发 API 已启动,配置目录:', OPENCLAW_DIR)
console.log('[dev-api] 平台:', isMac ? 'macOS' : process.platform)
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith('/__api/')) return next()
const cmd = req.url.slice(7).split('?')[0]
const handler = handlers[cmd]
if (!handler) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` }))
return
}
try {
const args = await readBody(req)
const result = await handler(args)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(result))
} catch (e) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: e.message || String(e) }))
}
})
}
ensureInit()
server.middlewares.use(_apiMiddleware)
},
configurePreviewServer(server) {
ensureInit()
server.middlewares.use(_apiMiddleware)
},
}
}

View File

@@ -140,6 +140,10 @@ install_clawpanel() {
cd "$INSTALL_DIR"
npm install
fi
# 生产构建(生成优化后的静态文件)
echo "📦 构建生产版本..."
cd "$INSTALL_DIR"
npx vite build
echo "✅ ClawPanel 安装完成: $INSTALL_DIR"
}
@@ -164,7 +168,7 @@ After=network.target
Type=simple
User=$(whoami)
WorkingDirectory=$INSTALL_DIR
ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -185,7 +189,7 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=$INSTALL_DIR
ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -210,6 +214,32 @@ get_local_ip() {
echo "localhost"
}
# 生成默认访问密码
setup_default_password() {
local config_dir="$HOME/.openclaw"
local config_file="$config_dir/clawpanel.json"
mkdir -p "$config_dir"
# 已存在配置且有密码则跳过
if [ -f "$config_file" ]; then
local existing_pw=$(grep -o '"accessPassword"[[:space:]]*:[[:space:]]*"[^"]*"' "$config_file" | head -1)
if [ -n "$existing_pw" ]; then
echo " 已有访问密码,跳过生成"
DEFAULT_PASSWORD=""
return
fi
fi
DEFAULT_PASSWORD="123456"
cat > "$config_file" <<EOF
{
"accessPassword": "123456",
"mustChangePassword": true
}
EOF
echo "✅ 已设置默认访问密码: 123456"
}
# 主流程
main() {
detect_os
@@ -218,6 +248,7 @@ main() {
install_node
install_openclaw
install_clawpanel
setup_default_password
setup_systemd
local ip=$(get_local_ip)
@@ -236,6 +267,11 @@ main() {
echo " 🌐 访问地址: http://${ip}:${PANEL_PORT}"
echo " 📁 安装目录: $INSTALL_DIR"
echo " 📋 配置目录: $HOME/.openclaw/"
if [ -n "$DEFAULT_PASSWORD" ]; then
echo ""
echo " 🔑 默认访问密码: $DEFAULT_PASSWORD"
echo " ⚠️ 首次登录后会要求修改密码,请妥善保管新密码!"
fi
echo ""
echo " 常用命令:"
echo " $ctl_cmd status clawpanel # 查看状态"

77
scripts/sync-version.js Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* 版本号同步脚本
* 以 package.json 为唯一版本源,同步到所有相关文件
*
* 用法:
* node scripts/sync-version.js # 同步当前版本
* node scripts/sync-version.js 0.6.0 # 先改 package.json 再同步
*/
import { readFileSync, writeFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
// 读取 package.json
const pkgPath = resolve(root, 'package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
// 如果传入了新版本号,先更新 package.json
const newVersion = process.argv[2]
if (newVersion) {
if (!/^\d+\.\d+\.\d+/.test(newVersion)) {
console.error('❌ 版本号格式不对,应为 x.y.z')
process.exit(1)
}
pkg.version = newVersion
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(`✅ package.json → ${newVersion}`)
}
const version = pkg.version
// 同步目标文件
const targets = [
{
file: 'src-tauri/tauri.conf.json',
update(content) {
const obj = JSON.parse(content)
obj.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: 'docs/index.html',
update(content) {
return content.replace(/"softwareVersion":\s*"[^"]*"/, `"softwareVersion": "${version}"`)
},
},
]
let changed = 0
for (const { file, update } of targets) {
const filepath = resolve(root, file)
try {
const before = readFileSync(filepath, 'utf8')
const after = update(before)
if (before !== after) {
writeFileSync(filepath, after)
console.log(`${file}${version}`)
changed++
} else {
console.log(` ${file} — 已是 ${version}`)
}
} catch (e) {
console.error(`${file}: ${e.message}`)
}
}
console.log(`\n版本 ${version}${changed ? `已同步 ${changed} 个文件` : '所有文件已是最新'}`)

2
src-tauri/Cargo.lock generated
View File

@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.5.1"
version = "0.5.7"
dependencies = [
"base64 0.22.1",
"chrono",

View File

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

View File

@@ -1323,6 +1323,29 @@ pub async fn check_panel_update() -> Result<Value, String> {
Ok(Value::Object(result))
}
// === 面板配置 (clawpanel.json) ===
#[tauri::command]
pub fn read_panel_config() -> Result<Value, String> {
let path = super::openclaw_dir().join("clawpanel.json");
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(&path).map_err(|e| format!("读取失败: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))
}
#[tauri::command]
pub fn write_panel_config(config: Value) -> Result<(), String> {
let dir = super::openclaw_dir();
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
}
let path = dir.join("clawpanel.json");
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
}
#[tauri::command]
pub fn get_npm_registry() -> Result<String, String> {
Ok(get_configured_registry())

View File

@@ -39,6 +39,8 @@ pub fn run() {
config::uninstall_gateway,
config::patch_model_vision,
config::check_panel_update,
config::read_panel_config,
config::write_panel_config,
config::get_npm_registry,
config::set_npm_registry,
// 设备密钥 + Gateway 握手

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.5.6",
"version": "0.5.7",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",

View File

@@ -4,6 +4,7 @@
import { navigate, getCurrentRoute } from '../router.js'
import { toggleTheme, getTheme } from '../lib/theme.js'
import { isOpenclawReady } from '../lib/app-state.js'
import { version as APP_VERSION } from '../../package.json'
const NAV_ITEMS_FULL = [
{
@@ -22,6 +23,7 @@ const NAV_ITEMS_FULL = [
{ route: '/models', label: '模型配置', icon: 'models' },
{ route: '/agents', label: 'Agent 管理', icon: 'agents' },
{ route: '/gateway', label: 'Gateway', icon: 'gateway' },
{ route: '/security', label: '安全设置', icon: 'security' },
]
},
{
@@ -81,6 +83,7 @@ const ICONS = {
extensions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>',
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
}
@@ -128,6 +131,10 @@ export function renderSidebar(el) {
${isDark ? sunIcon : moonIcon}
<span>${isDark ? '日间模式' : '夜间模式'}</span>
</div>
<div class="sidebar-meta">
<a href="https://claw.qt.cool" target="_blank" rel="noopener" class="sidebar-link">claw.qt.cool</a>
<span class="sidebar-version">v${APP_VERSION}</span>
</div>
</div>
`

View File

@@ -113,6 +113,10 @@ async function webInvoke(cmd, args) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
if (resp.status === 401 && window.__clawpanel_show_login) {
window.__clawpanel_show_login()
throw new Error('需要登录')
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }))
throw new Error(data.error || `HTTP ${resp.status}`)
@@ -298,6 +302,10 @@ export const api = {
deleteMemoryFile: (path, agentId) => { invalidate('list_memory_files'); return invoke('delete_memory_file', { path, agentId: agentId || null }) },
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }),
// 面板配置 (clawpanel.json)
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => invoke('write_panel_config', { config }),
// 安装/部署
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') },

View File

@@ -7,6 +7,7 @@ import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js'
import { wsClient } from './lib/ws-client.js'
import { api } from './lib/tauri-api.js'
import { version as APP_VERSION } from '../package.json'
// 样式
import './style/variables.css'
@@ -22,6 +23,130 @@ import './style/assistant.css'
// 初始化主题
initTheme()
// === 访问密码保护Web + 桌面端通用) ===
const isTauri = !!window.__TAURI_INTERNALS__
async function checkAuth() {
if (isTauri) {
// 桌面端:读 clawpanel.json检查密码配置
try {
const { api } = await import('./lib/tauri-api.js')
const cfg = await api.readPanelConfig()
if (!cfg.accessPassword) return { ok: true }
if (sessionStorage.getItem('clawpanel_authed') === '1') return { ok: true }
// 默认密码:直接传给登录页,避免二次读取
const defaultPw = (cfg.mustChangePassword && cfg.accessPassword) ? cfg.accessPassword : null
return { ok: false, defaultPw }
} catch { return { ok: true } }
}
// Web 模式
try {
const resp = await fetch('/__api/auth_check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
const data = await resp.json()
if (!data.required || data.authenticated) return { ok: true }
return { ok: false, defaultPw: data.defaultPassword || null }
} catch { return { ok: true } }
}
const _logoSvg = `<svg class="login-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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"/>
<path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
</svg>`
function _hideSplash() {
const splash = document.getElementById('splash')
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500) }
}
function showLoginOverlay(defaultPw) {
const hasDefault = !!defaultPw
const overlay = document.createElement('div')
overlay.id = 'login-overlay'
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>
<form id="login-form">
<input class="login-input" type="${hasDefault ? 'text' : 'password'}" id="login-pw" placeholder="访问密码" autocomplete="current-password" autofocus value="${hasDefault ? defaultPw : ''}" />
<button class="login-btn" type="submit">登 录</button>
<div class="login-error" id="login-error"></div>
</form>
<div style="margin-top:20px;font-size:11px;color:#aaa;text-align:center">
<a href="https://claw.qt.cool" target="_blank" rel="noopener" style="color:#aaa;text-decoration:none">claw.qt.cool</a>
<span style="margin:0 6px">·</span>v${APP_VERSION}
</div>
</div>
`
document.body.appendChild(overlay)
_hideSplash()
return new Promise((resolve) => {
overlay.querySelector('#login-form').addEventListener('submit', async (e) => {
e.preventDefault()
const pw = overlay.querySelector('#login-pw').value
const btn = overlay.querySelector('.login-btn')
const errEl = overlay.querySelector('#login-error')
btn.disabled = true
btn.textContent = '登录中...'
errEl.textContent = ''
try {
if (isTauri) {
// 桌面端:本地比对密码
const { api } = await import('./lib/tauri-api.js')
const cfg = await api.readPanelConfig()
if (pw !== cfg.accessPassword) {
errEl.textContent = '密码错误'
btn.disabled = false
btn.textContent = '登 录'
return
}
sessionStorage.setItem('clawpanel_authed', '1')
overlay.classList.add('hide')
setTimeout(() => overlay.remove(), 400)
if (cfg.accessPassword === '123456') {
sessionStorage.setItem('clawpanel_must_change_pw', '1')
}
resolve()
} else {
// Web 模式:调后端
const resp = await fetch('/__api/auth_login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
})
const data = await resp.json()
if (!resp.ok) {
errEl.textContent = data.error || '登录失败'
btn.disabled = false
btn.textContent = '登 录'
return
}
overlay.classList.add('hide')
setTimeout(() => overlay.remove(), 400)
if (data.mustChangePassword || data.defaultPassword === '123456') {
sessionStorage.setItem('clawpanel_must_change_pw', '1')
}
resolve()
}
} catch (err) {
errEl.textContent = '网络错误: ' + (err.message || err)
btn.disabled = false
btn.textContent = '登 录'
}
})
})
}
// 全局 401 拦截API 返回 401 时弹出登录
window.__clawpanel_show_login = async function() {
if (document.getElementById('login-overlay')) return
await showLoginOverlay()
location.reload()
}
const sidebar = document.getElementById('sidebar')
const content = document.getElementById('content')
@@ -37,6 +162,7 @@ async function boot() {
registerRoute('/gateway', () => import('./pages/gateway.js'))
registerRoute('/memory', () => import('./pages/memory.js'))
registerRoute('/extensions', () => import('./pages/extensions.js'))
registerRoute('/security', () => import('./pages/security.js'))
registerRoute('/about', () => import('./pages/about.js'))
registerRoute('/assistant', () => import('./pages/assistant.js'))
registerRoute('/setup', () => import('./pages/setup.js'))
@@ -51,6 +177,19 @@ async function boot() {
setTimeout(() => splash.remove(), 500)
}
// 默认密码提醒横幅
if (sessionStorage.getItem('clawpanel_must_change_pw') === '1') {
const banner = document.createElement('div')
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>⚠️ 当前使用的是系统生成的默认密码,为了安全请尽快修改</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>
<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)
}
// 后台检测状态,检测完再决定是否跳转 setup
detectOpenclawStatus().then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
@@ -240,4 +379,9 @@ function showGuardianRecovery() {
})
}
boot()
// 启动:先检查认证,再加载应用
;(async () => {
const auth = await checkAuth()
if (!auth.ok) await showLoginOverlay(auth.defaultPw)
boot()
})()

View File

@@ -42,6 +42,13 @@ const MODES = {
}
const DEFAULT_MODE = 'execute'
// ── API 类型 ──
const API_TYPES = [
{ value: 'openai', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic', label: 'Anthropic 原生' },
{ value: 'google-gemini', label: 'Google Gemini' },
]
// ── 系统提示词 ──
const DEFAULT_NAME = '晴辰助手'
const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
@@ -935,6 +942,7 @@ function loadConfig() {
if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
if (!_config.tools) _config.tools = { terminal: false, fileOps: false }
if (!_config.mode) _config.mode = DEFAULT_MODE
if (!_config.apiType) _config.apiType = 'openai'
return _config
}
@@ -1013,19 +1021,39 @@ function autoTitle(session) {
// ── AI API 调用(自动兼容 Chat Completions + Responses API──
function cleanBaseUrl(raw) {
function cleanBaseUrl(raw, apiType) {
let base = raw.replace(/\/+$/, '')
base = base.replace(/\/chat\/completions\/?$/, '')
base = base.replace(/\/completions\/?$/, '')
base = base.replace(/\/responses\/?$/, '')
base = base.replace(/\/messages\/?$/, '')
const type = apiType || _config.apiType || 'openai'
if (type === 'anthropic') {
// Anthropic: https://api.anthropic.com/v1
if (!base.endsWith('/v1')) base += '/v1'
return base
}
if (type === 'google-gemini') {
// Gemini: https://generativelanguage.googleapis.com/v1beta
return base
}
if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1')
return base
}
function authHeaders() {
function authHeaders(apiType, apiKey) {
const type = apiType || _config.apiType || 'openai'
const key = apiKey || _config.apiKey || ''
if (type === 'anthropic') {
return {
'Content-Type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
}
}
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_config.apiKey}`,
'Authorization': `Bearer ${key}`,
}
}
@@ -1051,7 +1079,19 @@ async function callAI(messages, onChunk) {
}, TIMEOUT_TOTAL)
try {
// 先尝试 Chat Completions API
const apiType = _config.apiType || 'openai'
if (apiType === 'anthropic') {
await callAnthropicMessages(base, allMessages, onChunk)
return
}
if (apiType === 'google-gemini') {
await callGeminiGenerate(base, allMessages, onChunk)
return
}
// OpenAI: 先尝试 Chat Completions API
try {
await callChatCompletions(base, allMessages, onChunk)
return
@@ -1064,7 +1104,6 @@ async function callAI(messages, onChunk) {
const msg = err.message || ''
if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) {
console.log('[assistant] Chat Completions 不支持此模型,自动切换到 Responses API')
// 重新创建 abort controller上一个可能已被消费
_abortController = new AbortController()
await callResponsesAPI(base, allMessages, onChunk)
return
@@ -1211,6 +1250,133 @@ async function callResponsesAPI(base, messages, onChunk) {
})
}
// ── Anthropic Messages API/v1/messages──
async function callAnthropicMessages(base, messages, onChunk) {
const url = base + '/messages'
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const chatMessages = messages.filter(m => m.role !== 'system')
const body = {
model: _config.model,
max_tokens: 8192,
stream: true,
temperature: _config.temperature || 0.7,
}
if (systemMsg) body.system = systemMsg
body.messages = chatMessages
const reqTime = Date.now()
_lastDebugInfo = {
url, method: 'POST',
requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
requestTime: new Date(reqTime).toLocaleString('zh-CN'),
}
const resp = await fetchWithRetry(url, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body),
signal: _abortController.signal,
})
_lastDebugInfo.status = resp.status
_lastDebugInfo.contentType = resp.headers.get('content-type') || ''
_lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN')
_lastDebugInfo.latency = Date.now() - reqTime + 'ms'
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
_lastDebugInfo.errorBody = errText.slice(0, 500)
let errMsg = `API 错误 ${resp.status}`
try {
const errJson = JSON.parse(errText)
errMsg = errJson.error?.message || errJson.message || errMsg
} catch {
if (errText) errMsg += `: ${errText.slice(0, 200)}`
}
throw new Error(errMsg)
}
_lastDebugInfo.streaming = true
let chunkCount = 0, contentChunks = 0, thinkingChunks = 0
let thinkingBuf = ''
await readSSEStream(resp, (json) => {
chunkCount++
if (json.type === 'content_block_delta') {
const delta = json.delta
if (delta?.type === 'text_delta' && delta.text) {
contentChunks++
onChunk(delta.text)
} else if (delta?.type === 'thinking_delta' && delta.thinking) {
thinkingChunks++
thinkingBuf += delta.thinking
}
}
})
_lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, thinking: thinkingChunks }
if (contentChunks === 0 && thinkingBuf) {
console.warn('[assistant] Anthropic: 无 text 块,使用 thinking 作为回复')
onChunk(thinkingBuf)
_lastDebugInfo.fallbackToThinking = true
}
}
// ── Google Gemini API ──
async function callGeminiGenerate(base, messages, onChunk) {
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const chatMessages = messages.filter(m => m.role !== 'system')
// Gemini 格式转换
const contents = chatMessages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
}))
const body = {
contents,
generationConfig: { temperature: _config.temperature || 0.7 },
}
if (systemMsg) {
body.systemInstruction = { parts: [{ text: systemMsg }] }
}
const url = `${base}/models/${_config.model}:streamGenerateContent?alt=sse&key=${_config.apiKey}`
const reqTime = Date.now()
_lastDebugInfo = { url: url.replace(_config.apiKey, '***'), method: 'POST', requestTime: new Date(reqTime).toLocaleString('zh-CN') }
const resp = await fetchWithRetry(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: _abortController.signal,
})
_lastDebugInfo.status = resp.status
_lastDebugInfo.latency = Date.now() - reqTime + 'ms'
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
let errMsg = `API 错误 ${resp.status}`
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
throw new Error(errMsg)
}
_lastDebugInfo.streaming = true
let chunkCount = 0
await readSSEStream(resp, (json) => {
chunkCount++
const text = json.candidates?.[0]?.content?.parts?.[0]?.text
if (text) onChunk(text)
})
_lastDebugInfo.chunks = { total: chunkCount }
}
// ── 通用 SSE 流读取 ──
async function readSSEStream(resp, onEvent) {
const reader = resp.body.getReader()
@@ -1380,21 +1546,57 @@ async function confirmToolCall(tc, critical = false) {
return result
}
// 将 OpenAI 格式工具定义转为 Anthropic 格式
function convertToolsForAnthropic(tools) {
return tools.map(t => ({
name: t.function.name,
description: t.function.description || '',
input_schema: t.function.parameters || { type: 'object', properties: {} },
}))
}
// 将 OpenAI 格式工具定义转为 Gemini 格式
function convertToolsForGemini(tools) {
return [{ functionDeclarations: tools.map(t => ({
name: t.function.name,
description: t.function.description || '',
parameters: t.function.parameters || { type: 'object', properties: {} },
}))}]
}
// 工具调用执行(共用逻辑)
async function executeToolWithSafety(toolName, args, tcForConfirm) {
let result = '', approved = true
const mode = MODES[currentMode()]
const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
if (isCritical) {
approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, true)
if (!approved) result = '用户拒绝了此危险操作'
} else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } })
if (!approved) result = '用户拒绝了此操作'
}
if (approved) {
try { result = await executeTool(toolName, args) }
catch (err) { result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` }
}
return { result, approved }
}
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
async function callAIWithTools(messages, onStatus, onToolProgress) {
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
}
const apiType = _config.apiType || 'openai'
const base = cleanBaseUrl(_config.baseUrl)
const tools = getEnabledTools()
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
const toolHistory = [] // 记录工具调用历史
const toolHistory = []
const MAX_AUTO_ROUNDS = 8
// 工具调用循环(无硬性上限,超过阈值后询问用户)
for (let round = 0; ; round++) {
// 超过自动轮次后,询问用户是否继续
if (round >= MAX_AUTO_ROUNDS) {
const answer = await showAskUserCard({
question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
@@ -1411,6 +1613,120 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
_abortController = new AbortController()
onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
// ── Anthropic 工具调用 ──
if (apiType === 'anthropic') {
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
const body = {
model: _config.model,
max_tokens: 8192,
temperature: _config.temperature || 0.7,
messages: chatMsgs,
}
if (systemMsg) body.system = systemMsg
if (tools.length > 0) body.tools = convertToolsForAnthropic(tools)
const resp = await fetchWithRetry(base + '/messages', {
method: 'POST', headers: authHeaders(), body: JSON.stringify(body),
signal: _abortController.signal,
})
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
let errMsg = `API 错误 ${resp.status}`
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
throw new Error(errMsg)
}
const data = await resp.json()
const contentBlocks = data.content || []
const toolUses = contentBlocks.filter(b => b.type === 'tool_use')
const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
if (toolUses.length > 0) {
// 将 assistant 消息加入上下文
currentMessages.push({ role: 'assistant', content: contentBlocks })
const toolResults = []
for (const tu of toolUses) {
const args = tu.input || {}
toolHistory.push({ name: tu.name, args, result: null, approved: true, pending: true })
onToolProgress(toolHistory)
const { result, approved } = await executeToolWithSafety(tu.name, args)
const last = toolHistory[toolHistory.length - 1]
last.result = result; last.approved = approved; last.pending = false
onToolProgress(toolHistory)
toolResults.push({
type: 'tool_result',
tool_use_id: tu.id,
content: typeof result === 'string' ? result : JSON.stringify(result),
})
}
currentMessages.push({ role: 'user', content: toolResults })
continue
}
return { content: textContent, toolHistory }
}
// ── Gemini 工具调用 ──
if (apiType === 'google-gemini') {
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
const contents = chatMsgs.map(m => ({
role: m.role === 'assistant' ? 'model' : m.role === 'tool' ? 'function' : 'user',
parts: m.functionResponse
? [{ functionResponse: m.functionResponse }]
: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
}))
const body = { contents, generationConfig: { temperature: _config.temperature || 0.7 } }
if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
if (tools.length > 0) body.tools = convertToolsForGemini(tools)
const url = `${base}/models/${_config.model}:generateContent?key=${_config.apiKey}`
const resp = await fetchWithRetry(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), signal: _abortController.signal,
})
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
let errMsg = `API 错误 ${resp.status}`
try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
throw new Error(errMsg)
}
const data = await resp.json()
const parts = data.candidates?.[0]?.content?.parts || []
const funcCalls = parts.filter(p => p.functionCall)
const textParts = parts.filter(p => p.text).map(p => p.text).join('')
if (funcCalls.length > 0) {
currentMessages.push({ role: 'assistant', content: textParts, _geminiParts: parts })
for (const fc of funcCalls) {
const args = fc.functionCall.args || {}
toolHistory.push({ name: fc.functionCall.name, args, result: null, approved: true, pending: true })
onToolProgress(toolHistory)
const { result, approved } = await executeToolWithSafety(fc.functionCall.name, args)
const last = toolHistory[toolHistory.length - 1]
last.result = result; last.approved = approved; last.pending = false
onToolProgress(toolHistory)
currentMessages.push({
role: 'tool',
content: typeof result === 'string' ? result : JSON.stringify(result),
functionResponse: { name: fc.functionCall.name, response: { result: typeof result === 'string' ? result : JSON.stringify(result) } },
})
}
continue
}
return { content: textParts, toolHistory }
}
// ── OpenAI 工具调用 ──
const body = {
model: _config.model,
messages: currentMessages,
@@ -1438,9 +1754,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
if (!assistantMsg) throw new Error('AI 未返回有效响应')
// 检查是否有 tool_calls
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
// 将 assistant 消息(含 tool_calls加入上下文
currentMessages.push(assistantMsg)
for (const tc of assistantMsg.tool_calls) {
@@ -1448,40 +1762,14 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
const toolName = tc.function.name
// 先显示"执行中"状态的工具块
toolHistory.push({ name: toolName, args, result: null, approved: true, pending: true })
onToolProgress(toolHistory)
let result = ''
let approved = true
// 安全围栏:极端危险命令任何模式都必须确认
const mode = MODES[currentMode()]
const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
if (isCritical) {
approved = await confirmToolCall(tc, true)
if (!approved) result = '用户拒绝了此危险操作'
} else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
approved = await confirmToolCall(tc)
if (!approved) result = '用户拒绝了此操作'
}
if (approved) {
try {
result = await executeTool(toolName, args)
} catch (err) {
result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`
}
}
// 更新工具调用历史(完成状态)
const { result, approved } = await executeToolWithSafety(toolName, args, tc)
const last = toolHistory[toolHistory.length - 1]
last.result = result
last.approved = approved
last.pending = false
last.result = result; last.approved = approved; last.pending = false
onToolProgress(toolHistory)
// 添加 tool 结果消息
currentMessages.push({
role: 'tool',
tool_call_id: tc.id,
@@ -1489,10 +1777,9 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
})
}
continue // 继续循环,让 AI 处理工具结果
continue
}
// 没有 tool_calls返回最终文本
const content = assistantMsg.content || assistantMsg.reasoning_content || ''
return { content, toolHistory }
}
@@ -1681,6 +1968,12 @@ function showSettings() {
<label class="form-label">API Base URL</label>
<input class="form-input" id="ast-baseurl" value="${escHtml(c.baseUrl)}" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group" style="width:170px">
<label class="form-label">API 类型</label>
<select class="form-input" id="ast-apitype">
${API_TYPES.map(t => `<option value="${t.value}" ${c.apiType === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
</div>
</div>
<div style="display:flex;gap:10px;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
@@ -1706,7 +1999,11 @@ function showSettings() {
<input class="form-input" id="ast-temp" type="number" value="${c.temperature || 0.7}" min="0" max="2" step="0.1">
</div>
</div>
<div class="form-hint" style="margin-top:-4px">自动兼容 Chat Completions 和 Responses API</div>
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${{
openai: '自动兼容 Chat Completions 和 Responses API',
anthropic: '使用 Anthropic Messages API/v1/messages',
'google-gemini': '使用 Gemini generateContent API',
}[c.apiType || 'openai']}</div>
</div>
<div class="ast-tab-panel" data-panel="tools">
<div class="form-hint" style="margin-bottom:10px">工具开关优先级高于模式设置。关闭的工具在任何模式下都不可用。</div>
@@ -1752,6 +2049,21 @@ function showSettings() {
})
})
// API 类型切换时更新提示文本和 placeholder
const apiTypeSelect = overlay.querySelector('#ast-apitype')
const apiHintEl = overlay.querySelector('#ast-api-hint')
const baseUrlInput = overlay.querySelector('#ast-baseurl')
const apiKeyInput = overlay.querySelector('#ast-apikey')
apiTypeSelect.addEventListener('change', () => {
const v = apiTypeSelect.value
const hints = { openai: '自动兼容 Chat Completions 和 Responses API', anthropic: '使用 Anthropic Messages API/v1/messages', 'google-gemini': '使用 Gemini generateContent API' }
const placeholders = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', 'google-gemini': 'https://generativelanguage.googleapis.com/v1beta' }
const keyPlaceholders = { openai: 'sk-...', anthropic: 'sk-ant-...', 'google-gemini': 'AIza...' }
apiHintEl.textContent = hints[v] || hints.openai
baseUrlInput.placeholder = placeholders[v] || placeholders.openai
apiKeyInput.placeholder = keyPlaceholders[v] || keyPlaceholders.openai
})
const resultEl = overlay.querySelector('#ast-test-result')
const modelInput = overlay.querySelector('#ast-model')
const dropdown = overlay.querySelector('#ast-model-dropdown')
@@ -1762,6 +2074,7 @@ function showSettings() {
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
const model = overlay.querySelector('#ast-model').value.trim()
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
if (!baseUrl || !apiKey) {
resultEl.innerHTML = '<span style="color:var(--warning)">请先填写 Base URL 和 API Key</span>'
return
@@ -1773,93 +2086,78 @@ function showSettings() {
btn.disabled = true
btn.textContent = '测试中...'
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在发送测试消息...</span>'
const base = cleanBaseUrl(baseUrl)
const hdrs = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }
const base = cleanBaseUrl(baseUrl, selApiType)
const hdrs = authHeaders(selApiType, apiKey)
const t0 = Date.now()
const reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
const reqUrl = base + '/chat/completions'
let respStatus = 0, respBody = '', reply = '', usedApi = 'Chat Completions', fallback = false
let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {}
try {
const resp = await fetch(reqUrl, {
method: 'POST', headers: hdrs,
body: JSON.stringify(reqBody),
signal: AbortSignal.timeout(30000),
})
respStatus = resp.status
respBody = await resp.text()
if (!resp.ok) {
// 检查是否需要切到 Responses API
if (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported')) {
fallback = true
}
}
if (!fallback) {
// 尝试从各种可能的格式中提取回复
if (selApiType === 'anthropic') {
usedApi = 'Anthropic Messages'
reqUrl = base + '/messages'
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
respStatus = resp.status; respBody = await resp.text()
try {
const data = JSON.parse(respBody)
const msg = data.choices?.[0]?.message
reply = msg?.content
|| msg?.reasoning_content
|| data.choices?.[0]?.text
|| data.output?.text
|| data.result?.output?.text
|| data.data?.choices?.[0]?.message?.content
|| ''
// 如果 content 为空但有 reasoning_content标记为推理内容
if (!msg?.content && msg?.reasoning_content) {
reply = '[推理内容] ' + reply
}
reply = data.content?.filter(b => b.type === 'text').map(b => b.text).join('') || ''
} catch {}
} else if (selApiType === 'google-gemini') {
usedApi = 'Gemini'
reqUrl = `${base}/models/${model}:generateContent?key=***`
reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] }
const realUrl = `${base}/models/${model}:generateContent?key=${apiKey}`
const resp = await fetch(realUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
respStatus = resp.status; respBody = await resp.text()
try {
const data = JSON.parse(respBody)
reply = data.candidates?.[0]?.content?.parts?.[0]?.text || ''
} catch {}
} else {
// OpenAI: Chat Completions + Responses fallback
usedApi = 'Chat Completions'
reqUrl = base + '/chat/completions'
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
respStatus = resp.status; respBody = await resp.text()
let fallback = false
if (!resp.ok && (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported'))) {
fallback = true
}
if (!fallback) {
try {
const data = JSON.parse(respBody)
const msg = data.choices?.[0]?.message
reply = msg?.content || msg?.reasoning_content || data.choices?.[0]?.text || data.output?.text || ''
if (!msg?.content && msg?.reasoning_content) reply = '[推理内容] ' + reply
} catch {}
}
if (fallback) {
usedApi = 'Responses'
reqUrl = base + '/responses'
reqBody = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 }
try {
const resp2 = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) })
respStatus = resp2.status; respBody = await resp2.text()
try { const d = JSON.parse(respBody); reply = d.output_text || d.output?.[0]?.content?.[0]?.text || '' } catch {}
} catch (err2) {
resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err2.message })
btn.disabled = false; btn.textContent = '测试'; return
}
}
}
} catch (err) {
const elapsed = Date.now() - t0
resultEl.innerHTML = buildTestResult({
success: false, elapsed, usedApi,
reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message,
})
btn.disabled = false; btn.textContent = '测试对话'; return
resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message })
btn.disabled = false; btn.textContent = '测试'; return
}
// Responses API fallback
if (fallback) {
usedApi = 'Responses'
const reqUrl2 = base + '/responses'
const reqBody2 = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 }
try {
const resp2 = await fetch(reqUrl2, {
method: 'POST', headers: hdrs,
body: JSON.stringify(reqBody2),
signal: AbortSignal.timeout(30000),
})
respStatus = resp2.status
respBody = await resp2.text()
try {
const data2 = JSON.parse(respBody)
reply = data2.output_text || data2.output?.[0]?.content?.[0]?.text || ''
} catch {}
} catch (err) {
const elapsed = Date.now() - t0
resultEl.innerHTML = buildTestResult({
success: false, elapsed, usedApi,
reqUrl: reqUrl2, reqBody: reqBody2, respStatus: 0, respBody: '', error: err.message,
})
btn.disabled = false; btn.textContent = '测试对话'; return
}
}
const elapsed = Date.now() - t0
resultEl.innerHTML = buildTestResult({
success: !!reply, elapsed, usedApi,
reqUrl: fallback ? base + '/responses' : reqUrl,
reqBody: fallback ? { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } : reqBody,
respStatus, respBody, reply,
})
resultEl.innerHTML = buildTestResult({ success: !!reply, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus, respBody, reply })
btn.disabled = false
btn.textContent = '测试对话'
btn.textContent = '测试'
}
// 获取模型列表
@@ -1874,27 +2172,55 @@ function showSettings() {
btn.disabled = true
btn.textContent = '获取中...'
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在获取模型列表...</span>'
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
try {
const base = cleanBaseUrl(baseUrl)
const resp = await fetch(base + '/models', {
headers: { 'Authorization': 'Bearer ' + apiKey },
signal: AbortSignal.timeout(10000),
})
if (!resp.ok) {
const text = await resp.text().catch(() => '')
let msg = 'HTTP ' + resp.status
try { msg = JSON.parse(text).error?.message || msg } catch {}
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
return
const base = cleanBaseUrl(baseUrl, selApiType)
const hdrs = authHeaders(selApiType, apiKey)
let models = []
if (selApiType === 'anthropic') {
// Anthropic: GET /v1/models
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
const text = await resp.text().catch(() => '')
let msg = 'HTTP ' + resp.status
try { msg = JSON.parse(text).error?.message || msg } catch {}
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
return
}
const data = await resp.json()
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
} else if (selApiType === 'google-gemini') {
// Gemini: GET /models?key=xxx
const resp = await fetch(base + '/models?key=' + apiKey, { signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
const text = await resp.text().catch(() => '')
let msg = 'HTTP ' + resp.status
try { msg = JSON.parse(text).error?.message || msg } catch {}
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
return
}
const data = await resp.json()
models = (data.models || []).map(m => m.name?.replace('models/', '') || m.name).filter(Boolean).sort()
} else {
// OpenAI: GET /v1/models
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
const text = await resp.text().catch(() => '')
let msg = 'HTTP ' + resp.status
try { msg = JSON.parse(text).error?.message || msg } catch {}
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(msg) + '</span>'
return
}
const data = await resp.json()
models = (data.data || []).map(m => m.id).filter(Boolean).sort()
}
const data = await resp.json()
const models = (data.data || []).map(m => m.id).filter(Boolean).sort()
if (models.length === 0) {
resultEl.innerHTML = '<span style="color:var(--warning)">未发现可用模型</span>'
return
}
resultEl.innerHTML = '<span style="color:var(--success)">✓ 发现 ' + models.length + ' 个模型,点击下方列表选择</span>'
// 显示下拉列表
dropdown.innerHTML = models.map(m =>
'<div class="ast-model-option" data-model="' + escHtml(m) + '">' + escHtml(m) + '</div>'
).join('')
@@ -1903,7 +2229,7 @@ function showSettings() {
resultEl.innerHTML = '<span style="color:var(--error)">✗ ' + escHtml(err.message) + '</span>'
} finally {
btn.disabled = false
btn.textContent = '获取模型列表'
btn.textContent = '拉取'
}
}
@@ -1935,6 +2261,7 @@ function showSettings() {
_config.apiKey = overlay.querySelector('#ast-apikey').value.trim()
_config.model = overlay.querySelector('#ast-model').value.trim()
_config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7
_config.apiType = overlay.querySelector('#ast-apitype').value || 'openai'
// 工具开关
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked

View File

@@ -292,7 +292,8 @@ function testWebSocket(page) {
api.readOpenclawConfig().then(config => {
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
addLog(`📡 连接地址: ${url}`)
addLog(`🔑 Token: ${token ? token.substring(0, 20) + '...' : '(空)'}`)
@@ -512,7 +513,8 @@ async function fixPairing(page) {
const config = await api.readOpenclawConfig()
const port = config?.gateway?.port || 18789
const token = config?.gateway?.auth?.token || ''
const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`
const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host
const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)

View File

@@ -9,7 +9,7 @@ import { showModal, showConfirm } from '../components/modal.js'
// API 接口类型选项
const API_TYPES = [
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
{ value: 'anthropic', label: 'Anthropic 原生' },
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
{ value: 'openai-responses', label: 'OpenAI Responses' },
{ value: 'google-gemini', label: 'Google Gemini' },
]
@@ -17,7 +17,7 @@ const API_TYPES = [
// 服务商快捷预设
const PROVIDER_PRESETS = [
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' },
]

293
src/pages/security.js Normal file
View File

@@ -0,0 +1,293 @@
/**
* 安全设置页面 — 访问密码管理 & 无视风险模式
* 支持 Web 部署模式和 Tauri 桌面端
*/
import { toast } from '../components/toast.js'
const isTauri = !!window.__TAURI_INTERNALS__
let _tauriApi = null
async function getTauriApi() {
if (!_tauriApi) _tauriApi = (await import('../lib/tauri-api.js')).api
return _tauriApi
}
async function apiCall(cmd, args = {}) {
if (isTauri) {
// 桌面端:通过 Tauri IPC 读写 clawpanel.json
const api = await getTauriApi()
const cfg = await api.readPanelConfig()
if (cmd === 'auth_status') {
const isDefault = cfg.accessPassword === '123456'
const result = { hasPassword: !!cfg.accessPassword, mustChangePassword: isDefault, ignoreRisk: !!cfg.ignoreRisk }
if (isDefault) result.defaultPassword = '123456'
return result
}
if (cmd === 'auth_change_password') {
if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error('当前密码错误')
const weakErr = checkPasswordStrengthLocal(args.newPassword)
if (weakErr) throw new Error(weakErr)
if (args.newPassword === cfg.accessPassword) throw new Error('新密码不能与旧密码相同')
cfg.accessPassword = args.newPassword
delete cfg.mustChangePassword
delete cfg.ignoreRisk
await api.writePanelConfig(cfg)
sessionStorage.setItem('clawpanel_authed', '1')
return { success: true }
}
if (cmd === 'auth_ignore_risk') {
if (args.enable) {
delete cfg.accessPassword
delete cfg.mustChangePassword
cfg.ignoreRisk = true
sessionStorage.removeItem('clawpanel_authed')
} else {
delete cfg.ignoreRisk
}
await api.writePanelConfig(cfg)
return { success: true }
}
throw new Error('未知命令: ' + cmd)
}
// Web 模式
const resp = await fetch(`/__api/${cmd}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`)
return data
}
function checkPasswordStrengthLocal(pw) {
if (!pw || pw.length < 6) return '密码至少 6 位'
if (pw.length > 64) return '密码不能超过 64 位'
if (/^\d+$/.test(pw)) return '密码不能是纯数字'
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码'
return null
}
function strengthLevel(pw) {
if (!pw) return { level: 0, text: '', color: '' }
if (pw.length < 6) return { level: 1, text: '太短', color: 'var(--error)' }
if (/^\d+$/.test(pw)) return { level: 1, text: '纯数字太弱', color: 'var(--error)' }
let score = 0
if (pw.length >= 8) score++
if (pw.length >= 12) score++
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++
if (/\d/.test(pw)) score++
if (/[^a-zA-Z0-9]/.test(pw)) score++
if (score <= 1) return { level: 2, text: '一般', color: 'var(--warning)' }
if (score <= 3) return { level: 3, text: '良好', color: 'var(--primary)' }
return { level: 4, text: '强', color: 'var(--success)' }
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header"><h1>安全设置</h1></div>
<div id="security-content">
<div class="config-section loading-placeholder" style="height:120px"></div>
</div>
`
loadStatus(page)
return page
}
async function loadStatus(page) {
const container = page.querySelector('#security-content')
try {
const status = await apiCall('auth_status')
renderContent(container, status)
} catch (e) {
container.innerHTML = `<div class="config-section"><p style="color:var(--error)">加载失败: ${e.message}</p></div>`
}
}
function renderContent(container, status) {
let html = ''
// 当前状态
const stateIcon = status.hasPassword ? '✅' : (status.ignoreRisk ? '⚠️' : '⚠️')
const stateText = status.hasPassword
? (status.mustChangePassword ? '使用默认密码(需修改)' : '已设置自定义密码')
: (status.ignoreRisk ? '无视风险模式(无密码)' : '未设置密码')
const stateColor = status.hasPassword && !status.mustChangePassword ? 'var(--success)' : 'var(--warning)'
html += `
<div class="config-section">
<div class="config-section-title">访问密码状态</div>
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid ${stateColor}">
<span style="font-size:20px">${stateIcon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary)">${stateText}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
${status.hasPassword
? (isTauri ? '每次打开应用需输入密码' : '远程访问需输入密码才能进入面板')
: (isTauri ? '任何人打开应用即可使用' : '任何人都可以直接访问面板')}
</div>
</div>
</div>
</div>
`
// 修改密码区域
html += `
<div class="config-section">
<div class="config-section-title">${status.hasPassword ? '修改密码' : '设置密码'}</div>
<form id="form-change-pw" style="max-width:400px">
${status.hasPassword ? `
<div style="margin-bottom:12px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">当前密码</label>
<input type="password" id="sec-old-pw" class="form-input" placeholder="输入当前密码" autocomplete="current-password" style="width:100%"
${status.defaultPassword ? `value="${status.defaultPassword}"` : ''}>
${status.defaultPassword ? '<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">已自动填充默认密码,直接设置新密码即可</div>' : ''}
</div>
` : ''}
<div style="margin-bottom:12px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">新密码</label>
<input type="password" id="sec-new-pw" class="form-input" placeholder="至少 6 位,不能纯数字" autocomplete="new-password" style="width:100%">
<div id="pw-strength" style="margin-top:6px;display:flex;align-items:center;gap:8px;min-height:20px"></div>
</div>
<div style="margin-bottom:16px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">确认新密码</label>
<input type="password" id="sec-confirm-pw" class="form-input" placeholder="再次输入新密码" autocomplete="new-password" style="width:100%">
</div>
<button type="submit" class="btn btn-primary btn-sm">${status.hasPassword ? '确认修改' : '设置密码'}</button>
<span id="change-pw-msg" style="margin-left:12px;font-size:var(--font-size-xs)"></span>
</form>
</div>
`
// 无视风险模式
html += `
<div class="config-section">
<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="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
无视风险模式
</div>
<div style="padding:12px 16px;background:${status.ignoreRisk ? 'rgba(239,68,68,0.08)' : 'var(--bg-tertiary)'};border-radius:var(--radius-sm);border:1px solid ${status.ignoreRisk ? 'rgba(239,68,68,0.2)' : 'var(--border-primary)'}">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<div>
<div style="font-weight:500;color:var(--text-primary)">关闭密码保护</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-top:4px;line-height:1.5">
开启后任何人都可以直接访问面板,无需输入密码。<br>
<strong style="color:var(--error)">仅建议在受信任的内网环境中使用。</strong>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="toggle-ignore-risk" ${status.ignoreRisk ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div id="ignore-risk-confirm" style="display:none;margin-top:12px;padding:12px 16px;background:rgba(239,68,68,0.06);border-radius:var(--radius-sm);border:1px solid rgba(239,68,68,0.15)">
<p style="font-size:var(--font-size-sm);color:var(--error);font-weight:600;margin-bottom:8px">确认关闭密码保护?</p>
<p style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:12px;line-height:1.5">
关闭后,<strong>任何能访问此服务器 IP 和端口的人</strong>都可以直接进入管理面板,查看和修改你的 AI 配置。
</p>
<div style="display:flex;gap:8px">
<button class="btn btn-sm" id="btn-confirm-ignore" style="background:var(--error);color:#fff;border:none">我了解风险,确认关闭</button>
<button class="btn btn-secondary btn-sm" id="btn-cancel-ignore">取消</button>
</div>
</div>
</div>
`
container.innerHTML = html
bindSecurityEvents(container, status)
}
function bindSecurityEvents(container, status) {
// 密码强度实时显示
const newPwInput = container.querySelector('#sec-new-pw')
const strengthEl = container.querySelector('#pw-strength')
if (newPwInput && strengthEl) {
newPwInput.addEventListener('input', () => {
const s = strengthLevel(newPwInput.value)
if (!newPwInput.value) { strengthEl.innerHTML = ''; return }
const bars = [1,2,3,4].map(i =>
`<div style="width:32px;height:4px;border-radius:2px;background:${i <= s.level ? s.color : 'var(--border-primary)'}"></div>`
).join('')
strengthEl.innerHTML = `${bars}<span style="font-size:11px;color:${s.color};font-weight:500">${s.text}</span>`
})
}
// 修改密码表单
const form = container.querySelector('#form-change-pw')
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault()
const oldPw = container.querySelector('#sec-old-pw')?.value || ''
const newPw = container.querySelector('#sec-new-pw')?.value || ''
const confirmPw = container.querySelector('#sec-confirm-pw')?.value || ''
const msgEl = container.querySelector('#change-pw-msg')
const btn = form.querySelector('button[type="submit"]')
if (newPw !== confirmPw) { msgEl.textContent = '两次输入的密码不一致'; msgEl.style.color = 'var(--error)'; return }
btn.disabled = true
btn.textContent = '提交中...'
msgEl.textContent = ''
try {
await apiCall('auth_change_password', { oldPassword: oldPw, newPassword: newPw })
msgEl.textContent = '密码修改成功'
msgEl.style.color = 'var(--success)'
toast('密码已更新', 'success')
// 清除默认密码横幅
sessionStorage.removeItem('clawpanel_must_change_pw')
const banner = document.getElementById('pw-change-banner')
if (banner) banner.remove()
setTimeout(() => loadStatus(container.closest('.page')), 1000)
} catch (err) {
msgEl.textContent = err.message
msgEl.style.color = 'var(--error)'
btn.disabled = false
btn.textContent = status.hasPassword ? '确认修改' : '设置密码'
}
})
}
// 无视风险模式开关
const toggle = container.querySelector('#toggle-ignore-risk')
const confirmBox = container.querySelector('#ignore-risk-confirm')
if (toggle && confirmBox) {
toggle.addEventListener('change', () => {
if (toggle.checked) {
// 想开启无视风险 → 显示确认框
confirmBox.style.display = 'block'
toggle.checked = false // 先不改,等用户确认
} else {
// 想关闭无视风险 → 直接关闭,刷新页面引导设密码
handleIgnoreRisk(container, false)
}
})
container.querySelector('#btn-confirm-ignore')?.addEventListener('click', () => {
handleIgnoreRisk(container, true)
})
container.querySelector('#btn-cancel-ignore')?.addEventListener('click', () => {
confirmBox.style.display = 'none'
})
}
}
async function handleIgnoreRisk(container, enable) {
try {
await apiCall('auth_ignore_risk', { enable })
if (enable) {
toast('已开启无视风险模式,密码保护已关闭', 'warning')
} else {
toast('无视风险模式已关闭,请设置新密码', 'info')
}
setTimeout(() => loadStatus(container.closest('.page')), 500)
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
}

View File

@@ -186,6 +186,61 @@ function renderSteps(page, { node, cliOk, config }) {
}
function renderInstallSection() {
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)">找不到已安装的 OpenClaw</strong>
<p style="margin:6px 0 2px">ClawPanel 桌面版只能管理<strong>本机</strong>安装的 OpenClaw。以下环境中的安装无法被检测到</p>
<ul style="margin:4px 0 8px 16px;padding:0">
${isWin ? `
<li><strong>WSL (Windows 子系统)</strong> — OpenClaw 装在 WSL 里Windows 侧无法访问</li>
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
` : ''}
${isMac ? `
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
<li><strong>远程服务器</strong> — 安装在其他机器上</li>
` : ''}
${!isWin && !isMac ? `
<li><strong>Docker 容器</strong> — 容器内的安装与宿主机隔离</li>
` : ''}
</ul>
<details style="cursor:pointer">
<summary style="font-weight:600;color:var(--primary);margin-bottom:6px">
在对应环境中安装管理面板
</summary>
<div style="margin-top:8px">
${isWin ? `
<div style="margin-bottom:10px">
<div style="font-weight:600;margin-bottom:4px">WSL 中使用 Web 版:</div>
<div style="margin-bottom:2px;opacity:0.8">打开 WSL 终端,一键部署 ClawPanel Web 版:</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://claw.qt.cool/deploy.sh | bash</code>
<div style="margin-top:4px;opacity:0.7">部署后在浏览器访问 WSL 的 IP 即可管理。</div>
</div>
` : ''}
<div style="margin-bottom:10px">
<div style="font-weight:600;margin-bottom:4px">Docker 容器中使用:</div>
<div style="margin-bottom:2px;opacity:0.8">在容器内安装 OpenClaw + ClawPanel Web 版:</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://claw.qt.cool/deploy.sh | bash</code>
</div>
<div>
<div style="font-weight:600;margin-bottom:4px">远程服务器:</div>
<div style="margin-bottom:2px;opacity:0.8">SSH 登录服务器后执行:</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://claw.qt.cool/deploy.sh | bash</code>
</div>
</div>
</details>
<div style="margin-top:6px;opacity:0.7">
或者,你也可以在本机重新安装 OpenClaw使用下方的「一键安装」
</div>
</div>`
}
return `
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
选择版本后点击安装,将自动执行 npm 全局安装。
@@ -215,6 +270,7 @@ function renderInstallSection() {
</select>
</div>
<button class="btn btn-primary btn-sm" id="btn-install">一键安装</button>
${envHint}
`
}

View File

@@ -329,3 +329,87 @@ mark {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* === 登录覆盖层 === */
#login-overlay {
position: fixed; inset: 0; z-index: 99998;
display: flex; align-items: center; justify-content: center;
background: var(--bg-primary, #f8f9fb);
transition: opacity 0.4s ease;
}
#login-overlay.hide { opacity: 0; pointer-events: none; }
.login-card {
width: 360px; max-width: 90vw; padding: 40px 32px;
background: var(--bg-card, #fff);
border: 1px solid var(--border, #e4e4e7);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.08);
text-align: center;
}
.login-card .login-logo {
width: 48px; height: 48px; margin: 0 auto 16px;
color: var(--primary, #6366f1);
}
.login-card .login-title {
font-size: 20px; font-weight: 600; margin-bottom: 4px;
color: var(--text-primary, #18181b);
}
.login-card .login-desc {
font-size: 13px; color: var(--text-secondary, #71717a);
margin-bottom: 24px;
}
.login-card .login-input {
width: 100%; padding: 10px 14px; font-size: 14px;
border: 1px solid var(--border, #e4e4e7);
border-radius: 8px; outline: none;
background: var(--bg-secondary, #f4f4f5);
color: var(--text-primary, #18181b);
transition: border-color 0.2s;
}
.login-card .login-input:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 3px rgba(99,102,241,0.12);
}
.login-card .login-btn {
width: 100%; margin-top: 16px; padding: 10px 0;
font-size: 14px; font-weight: 500;
border: none; border-radius: 8px; cursor: pointer;
background: var(--primary, #6366f1);
color: #fff; transition: opacity 0.2s;
}
.login-card .login-btn:hover { opacity: 0.9; }
.login-card .login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.login-card .login-error {
margin-top: 12px; font-size: 13px;
color: var(--error, #ef4444);
min-height: 20px;
}
/* === Toggle 开关 === */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px; height: 24px;
flex-shrink: 0;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
cursor: pointer; border-radius: 24px;
background: var(--border-primary, #d4d4d8);
transition: background 0.25s;
}
.toggle-slider::before {
content: '';
position: absolute; left: 2px; top: 2px;
width: 20px; height: 20px; border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
transition: transform 0.25s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--error, #ef4444);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}

View File

@@ -61,6 +61,26 @@
border-top: 1px solid var(--border-secondary);
}
.sidebar-meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px 2px;
font-size: 11px;
}
.sidebar-link {
color: var(--text-tertiary);
text-decoration: none;
transition: color 0.2s;
}
.sidebar-link:hover {
color: var(--primary);
}
.sidebar-version {
color: var(--text-tertiary);
opacity: 0.7;
}
.nav-section {
margin-bottom: var(--space-md);
}