mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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
|
||||
```
|
||||
|
||||
471
CONTRIBUTING.md
471
CONTRIBUTING.md
@@ -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 | 服务端 session(24h 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,不引入第三方框架
|
||||
- **注释**:所有代码注释使用中文
|
||||
- **风格**:简洁清晰,避免过度封装
|
||||
- **命名**:变量和函数使用驼峰命名(camelCase),CSS 类名使用短横线命名(kebab-case)
|
||||
- **命名**:变量和函数使用 camelCase,CSS 类名使用 kebab-case
|
||||
- **资源**:静态资源本地化,禁止引用远程 CDN
|
||||
- **异步**:页面 render() 中禁止阻塞式 await,数据加载走后台异步
|
||||
- **异步**:页面 `render()` 中禁止阻塞式 await,数据加载走后台异步
|
||||
- **版本**:只改 `package.json`,运行 `npm run version:sync` 同步
|
||||
|
||||
---
|
||||
|
||||
## 问题反馈
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
| 版本 | 支持状态 |
|
||||
|------|----------|
|
||||
| 0.3.x | ✅ 安全更新 |
|
||||
| < 0.3 | ❌ 不再维护 |
|
||||
| 0.5.x | ✅ 安全更新 |
|
||||
| 0.4.x | ⚠️ 仅关键修复 |
|
||||
| < 0.4 | ❌ 不再维护 |
|
||||
|
||||
## 报告安全漏洞
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
77
scripts/sync-version.js
Normal 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
2
src-tauri/Cargo.lock
generated
@@ -328,7 +328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.5.1"
|
||||
version = "0.5.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.5.6"
|
||||
version = "0.5.7"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 握手
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
146
src/main.js
146
src/main.js
@@ -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()
|
||||
})()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
293
src/pages/security.js
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user