mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-01 20:51:34 +08:00
feat: 飞书官方插件迁移 + 配对审批 + Gateway防卡死 + 微信升级修复 + 更新检测修复
- 飞书渠道从 @openclaw/feishu 迁移到 @larksuite/openclaw-lark 官方插件 - 保存飞书配置时自动禁用旧 feishu 插件,防止新旧插件冲突 - 所有主要渠道(飞书/Telegram/Discord/Slack)启用配对审批UI - gateway_command 增加20s超时,超时后force-kill+fresh start - 全平台启动前端口占用检查,防止Guardian无限拉起 - Linux gateway_command 补齐 Duration 导入和 cleanup_zombie 实现 - Guardian自动守护在Tauri桌面端也启用,轮询间隔30s→15s - 微信渠道:升级操作不再弹出扫码二维码,按钮文案区分安装/升级 - 版本更新检测:CI不再将minAppVersion写死为当前版本 - 部署脚本增强OpenClaw检测,支持已安装的官方版 - 日间/夜间模式圆形扩散切换动画(View Transitions API) - API错误信息完整展示(429限流等),URL自动转可点击链接 - 第三方API接入引导优化:移除内置密钥,引导式流程 - 修复全平台 Clippy 警告(strip_prefix/dead_code/unnecessary_unwrap等) - Rust代码格式化修复(cargo fmt) - toast组件支持HTML内容渲染 - Rust后端test_model返回详细错误信息
This commit is contained in:
120
.dockerignore
Normal file
120
.dockerignore
Normal file
@@ -0,0 +1,120 @@
|
||||
# =============================================================================
|
||||
# ClawPanel .dockerignore
|
||||
# 避免不必要的文件进入镜像,减小镜像大小
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 版本控制和元数据
|
||||
# -----------------------------------------------------------------------------
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 开发文件和配置
|
||||
# -----------------------------------------------------------------------------
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rust/Tauri 构建产物(桌面版相关)
|
||||
# -----------------------------------------------------------------------------
|
||||
src-tauri/target/
|
||||
src-tauri/Cargo.lock
|
||||
src-tauri/*.rs.bak
|
||||
src-tauri/gen/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 文档和静态资源(不参与构建)
|
||||
# -----------------------------------------------------------------------------
|
||||
docs/
|
||||
*.png
|
||||
!src-tauri/icons/*.png
|
||||
*.ico
|
||||
!src-tauri/icons/*.ico
|
||||
*.icns
|
||||
!src-tauri/icons/*.icns
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 测试文件
|
||||
# -----------------------------------------------------------------------------
|
||||
**/*.test.js
|
||||
**/*.test.ts
|
||||
**/*.spec.js
|
||||
**/*.spec.ts
|
||||
**/coverage/
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 临时文件和缓存
|
||||
# -----------------------------------------------------------------------------
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
.npm/
|
||||
.yarn/
|
||||
.vite/
|
||||
|
||||
# 但保留必要的配置文件
|
||||
!vite.config.js
|
||||
!package.json
|
||||
!package-lock.json
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 脚本文件
|
||||
# -----------------------------------------------------------------------------
|
||||
scripts/*.js
|
||||
scripts/*.sh
|
||||
!scripts/serve.js
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CI/CD 配置
|
||||
# -----------------------------------------------------------------------------
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
Jenkinsfile
|
||||
azure-pipelines.yml
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 本地开发和调试文件
|
||||
# -----------------------------------------------------------------------------
|
||||
*.local
|
||||
*.debug
|
||||
dev.ps1
|
||||
dev.sh
|
||||
build.ps1
|
||||
build.sh
|
||||
deploy.sh
|
||||
linux-deploy.sh
|
||||
*.bak
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 其他不需要的文件
|
||||
# -----------------------------------------------------------------------------
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.tar
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 隐藏文件
|
||||
# -----------------------------------------------------------------------------
|
||||
.dockerignore
|
||||
.windsurf/
|
||||
.tmp/
|
||||
.context.md
|
||||
BLOCKING_ISSUES_REPORT.md
|
||||
LOBSTER-LEGION-ARCHIVE.md
|
||||
SECURITY.md
|
||||
CONTRIBUTING.md
|
||||
openclaw-version-policy.json
|
||||
__clawapp-chat-ref.js
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -166,12 +166,15 @@ jobs:
|
||||
# 上传为 Release Asset
|
||||
gh release upload "$TAG_NAME" "web-${VERSION}.zip" --clobber
|
||||
|
||||
# 读取现有 minAppVersion(前端热更新通常不需要更新 Rust 后端,保留旧值)
|
||||
MIN_APP_VER=$(cat docs/update/latest.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('minAppVersion','0.9.0'))" 2>/dev/null || echo "0.9.0")
|
||||
|
||||
# 更新 docs/update/latest.json
|
||||
DL_URL="https://github.com/${{ github.repository }}/releases/download/${TAG_NAME}/web-${VERSION}.zip"
|
||||
cat > docs/update/latest.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"minAppVersion": "${VERSION}",
|
||||
"minAppVersion": "${MIN_APP_VER}",
|
||||
"hash": "sha256:${HASH}",
|
||||
"url": "${DL_URL}",
|
||||
"size": ${SIZE},
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
.cursor
|
||||
docs/issue-solutions.md
|
||||
# 构建产物
|
||||
dist/
|
||||
src-tauri/target/
|
||||
@@ -52,4 +54,9 @@ docs/promo-video.mp4
|
||||
|
||||
# Rust 开发工具
|
||||
src-tauri/.cargo/
|
||||
.codex/
|
||||
.codex/
|
||||
# AI 上下文记忆
|
||||
.context.md
|
||||
CONTEXT_STATE.md
|
||||
PROJECT_CONTRACT.md
|
||||
project.yaml
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -5,6 +5,64 @@
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [0.9.8] - 2026-03-23
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
- **渠道管理全面增强** — 新增渠道列表 + Agent 绑定双面板布局,支持渠道卡片批量管理
|
||||
- **10 大消息渠道全覆盖** — QQBot、Telegram、Discord、Slack、飞书、钉钉、微信、Signal、Matrix、MS Teams 全部支持面板内配置、保存、校验
|
||||
- **Signal 在线校验** — 新增 signal-cli HTTP daemon 连通性检测(/v1/about 端点),返回 API 版本信息
|
||||
- **MS Teams 在线校验** — 新增 Azure AD OAuth2 client_credentials 流程校验 App ID / App Password / Tenant ID
|
||||
- **微信 ClawBot 集成** — 腾讯微信官方 `@tencent-weixin/openclaw-weixin` 插件一键安装 + 扫码登录,QR 码 Canvas 渲染(手机可扫描)、插件版本检测与升级提示、登录后自动写入渠道配置并刷新列表
|
||||
- **QQ 渠道诊断** — QQBot 渠道增加专属诊断工具,检测插件安装、配置状态
|
||||
- **Agent 绑定 CRUD** — 支持在面板内直接创建/编辑/删除 Agent 路由绑定
|
||||
- **渠道标签映射** — 新增 channel-labels.js,统一中文渠道名称(如 telegram→Telegram, discord→Discord)
|
||||
- **Docker 部署支持** — 新增 Dockerfile 多阶段构建 + docker-compose.yml + 一键部署脚本
|
||||
- **Skills 管理增强** — Skill 验证、扫描、安装功能全面增强,支持 fullPath 检测
|
||||
- **Messaging 插件迁移** — QQBot 插件自动迁移到 @tencent-connect/openclaw-qqbot
|
||||
|
||||
### 修复 (Fixes)
|
||||
|
||||
- **WhatsApp 渠道移除** — 上游 WhatsApp 插件运行时未加载(Gateway `web.login.start` 返回 `not available`),暂时移除;改用微信官方渠道替代
|
||||
- **messaging.rs 编译错误修复** — 修复 `insert_array_as_csv` 缺少引用、Matrix/MS Teams 保存时 `cfg` 双重可变借用导致编译失败
|
||||
- **Gateway PID 检测逻辑修复** — Windows `is_process_alive` 从错误的前缀匹配改为精确 PID 字段解析
|
||||
- **JSON 配置修复重写** — `fix_common_json_errors` 单引号修复和注释剥离完全重写,避免截断 URL 中的 `//`
|
||||
- **Linux 异步阻塞修复** — `check_service_status` 和 `start_service_impl` 中的同步 TCP 连接改用 `spawn_blocking`
|
||||
- **XSS 安全修复** — channels.js `showWarning`、main.js `errMsg`、agents.js `renderBindingBadges` 和错误加载均添加 HTML 转义
|
||||
- **渠道卡片编辑按钮修复** — 已接入渠道卡片的「编辑」按钮缺失 click handler,点击无响应;现已补全事件绑定
|
||||
- **微信渠道检测修复** — 微信登录后自动写入 `channels.openclaw-weixin` 配置,修复 `platform_list_id` / `platform_storage_key` 双向映射,登录后立即刷新列表
|
||||
- **Vite 代理修复** — 移除重复 `ws: true`、无效 `econnreset` 事件监听,修复 WebSocket socket 错误处理
|
||||
- **Docker 部署修复** — .dockerignore 不再排除 src/,volume 挂载路径与 Dockerfile USER 一致
|
||||
- **心跳检测修复** — WebSocket 首次连接时 `_lastMessageAt` 初始化为 `Date.now()`,避免心跳永远不触发
|
||||
- **PID 安全假设修复** — `get_gateway_pid_by_port` 读不到命令行时不再假定为 Gateway
|
||||
|
||||
### 改进 (Improvements)
|
||||
|
||||
- **Win11 wmic 兼容** — `read_process_command_line` 优先使用 PowerShell `Get-CimInstance`,fallback 到 wmic
|
||||
- **macOS Intel 路径兼容** — 版本检测和来源检测同时查找 `/opt/homebrew`(ARM)和 `/usr/local`(Intel)
|
||||
- **macOS PID 检测** — 服务状态检测新增 `lsof` 获取 PID,不再始终返回 None
|
||||
- **Windows 路径兼容** — Skills fullPath 验证支持 Windows 盘符路径(如 `C:\`)
|
||||
- **gateway_listen_port 缓存** — 新增 5 秒缓存,避免服务检测时频繁读文件解析 JSON
|
||||
- **第三方 API 接入引导优化** — 移除内置密钥,改为引导式流程(注册→填密钥→选模型),新增助手↔OpenClaw 双向同步按钮(带确认框)
|
||||
- **API 错误信息完整展示** — 模型测试和助手测试的 API 错误(如 429 限流)完整显示 error.message,URL 自动转为可点击链接,方便排查和引流
|
||||
- **飞书渠道升级** — 从 `@openclaw/feishu` 迁移到飞书官方插件 `@larksuite/openclaw-lark`,支持文档读写、多维表格、日程等高级能力,一键扫码创建机器人
|
||||
- **Gateway 重启防卡死** — `gateway_command` 增加 20s 超时,超时后自动 force-kill 残留进程并 fresh start;全平台启动前端口占用检查防止重复拉起;Guardian 自动守护在 Tauri 桌面端也启用;状态轮询间隔从 30s 缩短至 15s
|
||||
- **Regex 编译优化** — 多行注释正则改用 LazyLock 静态编译
|
||||
- **Agent 配置注释修正** — `agents.profiles` 注释修正为上游实际的 `agents.list`
|
||||
- **Linux cmd 候选清理** — 移除 Unix 平台上无意义的 `openclaw.cmd` 候选路径
|
||||
|
||||
### 待规划 (Planned)
|
||||
|
||||
> 以下为已归档的规划方案摘要,原独立文档已清理。
|
||||
|
||||
- **Gateway 运行检测重构** — 用统一的「端口 + HTTP 探针」方案替换当前各平台复杂的进程/netstat/launchctl 检测逻辑,跨 Windows/macOS/Linux 统一实现。核心思路:先查端口占用确认进程存在,再发 HTTP 请求确认是 Gateway(`GET /v1/health` 或 `/v1/version`)。详见原 `docs/gateway-detection-plan.md`
|
||||
- **AI 助手功能扩展** — 五大模块:① Docker/WSL 管理(容器操作、镜像管理)② Web 搜索(搜索引擎集成、结果注入上下文)③ SSH 远程管理(连接/命令/文件传输)④ 知识库/灵魂迁移(导入导出 Agent 灵魂与知识)⑤ 模型配置自动导入(从服务商 API 自动发现模型)。详见原 `docs/assistant-features-plan.md`
|
||||
- **Docker 多实例管理** — API 代理 + 实例切换架构,支持一台机器部署多个 OpenClaw Docker 实例并在面板内统一调度。涉及 dev-api.js 代理层、前端实例选择器、数据隔离。详见原 `docs/docker-multi-instance-plan.md`
|
||||
- **国际化 (i18n)** — 基于 i18n.js 核心模块实现中英双语,语言包 JSON 结构,按页面逐步迁移硬编码中文字符串。包含语言检测、降级策略、参数插值。详见原 `docs/i18n-plan.md`
|
||||
- **命令执行权限管理** — AI 助手执行终端命令时支持白名单/黑名单规则,四种模式(确认/白名单/黑名单/无限),glob 通配符匹配,存储于 `clawpanel.json`。详见原 `docs/ROADMAP-v0.9.md`
|
||||
- **安装体验优化** — 默认安装原版包、保存自定义 Node.js 路径后实时生效验证、Linux Web 版后台一键更新机制
|
||||
- **其他** — 渠道级消息统计、更多国内模型服务商预设、Rust 原生 Docker API(bollard)、前端热更新增量包
|
||||
|
||||
## [0.9.7] - 2026-03-21
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
91
Dockerfile
Normal file
91
Dockerfile
Normal file
@@ -0,0 +1,91 @@
|
||||
# =============================================================================
|
||||
# ClawPanel Dockerfile - 多阶段构建
|
||||
# 支持 Docker BuildKit,提供优化的生产镜像
|
||||
# =============================================================================
|
||||
#
|
||||
# 构建命令:
|
||||
# docker build -t clawpanel .
|
||||
# docker build -t clawpanel --build-arg NPM_REGISTRY=https://registry.npmmirror.com .
|
||||
#
|
||||
# 或使用 Docker Compose:
|
||||
# docker compose up -d
|
||||
#
|
||||
# 访问地址: http://localhost:1420
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 阶段 1: 构建阶段 (builder)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 复制项目文件
|
||||
COPY package*.json ./
|
||||
COPY vite.config.js ./
|
||||
COPY index.html ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY src/ ./src/
|
||||
|
||||
# 安装依赖并构建
|
||||
RUN npm ci --prefer-offline --registry https://registry.npmmirror.com && \
|
||||
npm run build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 阶段 2: 生产阶段 (production)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:22-alpine AS production
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
bash \
|
||||
tzdata
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/root
|
||||
|
||||
# 创建非 root 用户 (可选,主要用于日志查看)
|
||||
RUN addgroup -g 1000 appgroup && \
|
||||
adduser -u 1000 -G appgroup -s /bin/sh -D appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder --chown=appuser:appgroup /build/dist ./dist
|
||||
COPY --from=builder --chown=appuser:appgroup /build/scripts ./scripts
|
||||
COPY --from=builder --chown=appuser:appgroup /build/package*.json ./
|
||||
COPY --from=builder --chown=appuser:appgroup /build/node_modules ./node_modules
|
||||
|
||||
# 安装 OpenClaw CLI(用于读写配置)
|
||||
# 使用国内镜像源加速
|
||||
RUN npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com || \
|
||||
npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 1420
|
||||
|
||||
# 使用 root 用户运行(确保能管理 Gateway 等)
|
||||
# 如需安全性,可切换到 appuser,但需确保卷挂载权限正确
|
||||
USER root
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:1420/ || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["node", "scripts/serve.js"]
|
||||
163
docker-compose.yml
Normal file
163
docker-compose.yml
Normal file
@@ -0,0 +1,163 @@
|
||||
# =============================================================================
|
||||
# ClawPanel Docker Compose 配置
|
||||
# =============================================================================
|
||||
# 使用方法:
|
||||
# 1. 快速启动: docker compose up -d
|
||||
# 2. 查看日志: docker compose logs -f
|
||||
# 3. 停止服务: docker compose down
|
||||
# 4. 重新构建: docker compose up -d --build
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClawPanel Web 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
clawpanel:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# 使用 BuildKit 构建缓存
|
||||
cache_from:
|
||||
- clawpanel:build-cache
|
||||
args:
|
||||
- NPM_REGISTRY=https://registry.npmmirror.com
|
||||
container_name: clawpanel
|
||||
hostname: clawpanel
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 网络配置 - 关键:使用 host 网络模式解决容器内外互通问题
|
||||
# -------------------------------------------------------------------------
|
||||
# host 模式:容器共享宿主机网络命名空间
|
||||
# 优点:
|
||||
# 1. 容器可以直接访问宿主机上的服务(如 Gateway)
|
||||
# 2. 避免端口映射的复杂性
|
||||
# 3. 性能更好(无额外网络层)
|
||||
# 注意:使用 host 模式后,ports 配置无效
|
||||
# -------------------------------------------------------------------------
|
||||
network_mode: host
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 端口映射(仅在 network_mode 为 default 时生效)
|
||||
# 如果你想用 bridge 模式而不是 host 模式,注释上面一行,取消下面注释
|
||||
# -------------------------------------------------------------------------
|
||||
# ports:
|
||||
# - "1420:1420" # ClawPanel Web 界面
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 卷挂载 - 数据持久化
|
||||
# -------------------------------------------------------------------------
|
||||
volumes:
|
||||
# OpenClaw 配置目录 - 关键!
|
||||
# 包含 openclaw.json, mcp.json, agents/, devices/ 等
|
||||
- ~/.openclaw:/root/.openclaw
|
||||
# ClawPanel 数据目录(可选,用于存储临时文件)
|
||||
- ./data:/app/data
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 环境变量
|
||||
# -------------------------------------------------------------------------
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# OpenClaw Gateway 地址(如果 Gateway 不在本地,修改为实际地址)
|
||||
# 注意:host 模式下直接使用 localhost 或 127.0.0.1
|
||||
- OPENCLAW_URL=http://127.0.0.1:18789
|
||||
# 如果需要代理(取消注释并修改为实际代理地址)
|
||||
# - HTTP_PROXY=http://host.docker.internal:7890
|
||||
# - HTTPS_PROXY=http://host.docker.internal:7890
|
||||
# 时区设置
|
||||
- TZ=Asia/Shanghai
|
||||
# 确保 HOME 环境变量正确
|
||||
- HOME=/root
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 重启策略
|
||||
# -------------------------------------------------------------------------
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 健康检查
|
||||
# -------------------------------------------------------------------------
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:1420/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 安全配置
|
||||
# -------------------------------------------------------------------------
|
||||
# 使用读安全选项减少攻击面
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 资源限制(可选,取消注释以限制资源使用)
|
||||
# -------------------------------------------------------------------------
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: '1.0'
|
||||
# memory: 1G
|
||||
# reservations:
|
||||
# cpus: '0.5'
|
||||
# memory: 512M
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 日志配置
|
||||
# -------------------------------------------------------------------------
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 依赖(可选,如果 Gateway 在另一个容器中)
|
||||
# -------------------------------------------------------------------------
|
||||
# depends_on:
|
||||
# gateway:
|
||||
# condition: service_healthy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenClaw Gateway 服务(可选,取消注释以启用)
|
||||
# ---------------------------------------------------------------------------
|
||||
# gateway:
|
||||
# image: node:22-alpine
|
||||
# container_name: openclaw-gateway
|
||||
# hostname: openclaw-gateway
|
||||
# network_mode: host
|
||||
# volumes:
|
||||
# - ~/.openclaw:/root/.openclaw
|
||||
# environment:
|
||||
# - TZ=Asia/Shanghai
|
||||
# restart: unless-stopped
|
||||
# command: >
|
||||
# sh -c "npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com &&
|
||||
# openclaw init 2>/dev/null || true &&
|
||||
# openclaw gateway start --foreground"
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:18789/health"]
|
||||
# interval: 30s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
# logging:
|
||||
# driver: "json-file"
|
||||
# options:
|
||||
# max-size: "10m"
|
||||
# max-file: "3"
|
||||
|
||||
# =============================================================================
|
||||
# 网络配置(使用 host 模式时不需要)
|
||||
# =============================================================================
|
||||
# networks:
|
||||
# default:
|
||||
# name: clawpanel-network
|
||||
# driver: bridge
|
||||
|
||||
# =============================================================================
|
||||
# 卷配置
|
||||
# =============================================================================
|
||||
# volumes:
|
||||
# openclaw-config:
|
||||
# name: clawpanel-openclaw-config
|
||||
@@ -1,89 +0,0 @@
|
||||
# ClawPanel v0.9.0 规划
|
||||
|
||||
## 命令执行权限管理 (Issue #55)
|
||||
|
||||
### 需求
|
||||
AI 助手执行终端命令时,允许用户配置白名单/黑名单规则,控制哪些命令可以执行。
|
||||
|
||||
### 规则类型
|
||||
|
||||
| 类型 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| 精确匹配 | `go run main.go` | 只允许/禁止这一条完整命令 |
|
||||
| 前缀通配 | `go *` | 允许/禁止所有 go 开头的命令 |
|
||||
| 全局 | `*` | 允许/禁止所有命令 |
|
||||
|
||||
### 配置模式
|
||||
|
||||
- **确认模式**(默认)— 每条命令都弹窗确认
|
||||
- **白名单模式** — 匹配白名单的命令自动执行,其余弹窗确认
|
||||
- **黑名单模式** — 匹配黑名单的命令直接拒绝,其余弹窗确认
|
||||
- **无限模式** — 所有命令自动执行(当前已有的 unlimited 模式)
|
||||
|
||||
### 存储位置
|
||||
|
||||
`~/.openclaw/clawpanel.json` → `commandRules` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"commandRules": {
|
||||
"mode": "whitelist",
|
||||
"rules": [
|
||||
{ "pattern": "go run *", "action": "allow" },
|
||||
{ "pattern": "npm *", "action": "allow" },
|
||||
{ "pattern": "rm -rf /", "action": "deny" },
|
||||
{ "pattern": "git *", "action": "allow" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 实现方案
|
||||
|
||||
1. **设置页面 UI** — 安全设置页面新增「命令规则」区域
|
||||
- 规则列表(增删改)
|
||||
- 模式切换(确认/白名单/黑名单/无限)
|
||||
- 预设模板(开发者常用、安全最小权限等)
|
||||
|
||||
2. **存储** — 读写 `clawpanel.json` 的 `commandRules`
|
||||
|
||||
3. **拦截逻辑** — `assistant.js` 的 `assistant_exec` 调用前
|
||||
- 解析命令字符串
|
||||
- 按规则列表逐条匹配(支持 glob 通配符)
|
||||
- 匹配到 allow → 自动执行
|
||||
- 匹配到 deny → 直接拒绝并提示
|
||||
- 无匹配 → 按模式决定(弹窗确认或拒绝)
|
||||
|
||||
4. **Rust 后端** — `assistant_exec` 命令增加规则检查
|
||||
- 从 `clawpanel.json` 读取规则
|
||||
- glob 匹配逻辑
|
||||
- 返回 `{ allowed: bool, rule: string }` 给前端
|
||||
|
||||
### 优先级
|
||||
|
||||
中等。当前 AI 助手已有 4 种模式(聊天/规划/执行/无限),命令规则是对「执行」模式的细化增强。
|
||||
|
||||
---
|
||||
|
||||
## 安装体验优化
|
||||
|
||||
### 默认安装原版包
|
||||
当前版本选择器默认选中「汉化版」,改为默认「原版」(official)。用户可切换。
|
||||
|
||||
### 安装页面环境检测实时生效
|
||||
保存自定义 Node.js 路径后,检查是否需要重启才生效。v0.8.0 已改用 RwLock 支持运行时刷新,需验证是否完全生效。
|
||||
|
||||
### Linux Web 版后台更新机制
|
||||
Linux Web 版检测到新版本后,目前只能手动 `git pull`。需要实现:
|
||||
- 面板内一键更新按钮(自动 git pull + npm install + 重启服务)
|
||||
- 或者 systemd 服务自动拉取更新
|
||||
|
||||
---
|
||||
|
||||
## 其他待规划功能
|
||||
|
||||
- [ ] 消息渠道:渠道级别的消息统计(收发量、响应时间)
|
||||
- [ ] 模型配置:支持更多服务商预设(硅基流动、智谱、百川、通义千问等国内模型)
|
||||
- [ ] Docker 桌面版:Rust 原生 Docker API(bollard crate)替代 Node.js 后端
|
||||
- [ ] 前端热更新增量包:只下载变更文件,减小更新包体积
|
||||
- [ ] 多语言支持:i18n 框架(中/英双语)
|
||||
@@ -1,777 +0,0 @@
|
||||
# AI 助手功能扩展规划
|
||||
|
||||
> 基于现有工具架构(TOOL_DEFS + executeTool + getEnabledTools),扩展 6 大能力模块。
|
||||
> 每个模块独立开关,遵循现有的 `_config.tools.xxx` + 设置面板 toggle 模式。
|
||||
|
||||
---
|
||||
|
||||
## 当前架构概览
|
||||
|
||||
```
|
||||
TOOL_DEFS = {
|
||||
system: [get_system_info] // 始终可用
|
||||
process: [list_processes, check_port] // 始终可用
|
||||
interaction: [ask_user] // 始终可用
|
||||
terminal: [run_command] // 开关控制
|
||||
fileOps: [read_file, write_file, list_directory] // 开关控制
|
||||
}
|
||||
|
||||
_config.tools = { terminal: true/false, fileOps: true/false }
|
||||
```
|
||||
|
||||
扩展后:
|
||||
|
||||
```
|
||||
TOOL_DEFS = {
|
||||
...existing,
|
||||
docker: [docker_list, docker_exec, docker_logs, wsl_exec] // 新增
|
||||
webSearch: [web_search, fetch_url] // 新增
|
||||
ssh: [ssh_exec, ssh_read_file, ssh_write_file] // 新增
|
||||
knowledge: [search_knowledge] // 新增
|
||||
}
|
||||
|
||||
_config.tools = {
|
||||
...existing,
|
||||
docker: false, // 默认关闭
|
||||
webSearch: false, // 默认关闭
|
||||
ssh: false, // 默认关闭
|
||||
knowledge: false, // 默认关闭
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块一:Docker / WSL 管理工具
|
||||
|
||||
### 场景
|
||||
- 用户的 OpenClaw 可能安装在 Docker 容器或 WSL 中
|
||||
- 本地检测不到时,帮用户在容器/WSL 内操作
|
||||
- 查看容器日志、进入容器执行命令、管理容器生命周期
|
||||
|
||||
### 工具定义
|
||||
|
||||
| 工具名 | 描述 | 参数 | 危险等级 |
|
||||
|--------|------|------|----------|
|
||||
| `docker_list` | 列出 Docker 容器 | `filter?`, `all?` | 安全 |
|
||||
| `docker_exec` | 在容器内执行命令 | `container`, `command` | ⚠️ 危险 |
|
||||
| `docker_logs` | 查看容器日志 | `container`, `lines?` | 安全 |
|
||||
| `docker_compose` | 执行 docker-compose 命令 | `action`, `file?`, `service?` | ⚠️ 危险 |
|
||||
| `wsl_exec` | 在 WSL 内执行命令 | `distro?`, `command` | ⚠️ 危险 |
|
||||
| `wsl_list` | 列出 WSL 发行版 | — | 安全 |
|
||||
|
||||
### 后端实现
|
||||
|
||||
```
|
||||
Tauri (Rust):
|
||||
- docker_list → Command::new("docker").args(["ps", ...])
|
||||
- docker_exec → Command::new("docker").args(["exec", container, ...])
|
||||
- wsl_exec → Command::new("wsl").args(["-d", distro, "-e", ...]) (Windows only)
|
||||
|
||||
dev-api.js (Web):
|
||||
- execSync('docker ps --format json')
|
||||
- execSync(`docker exec ${container} ${command}`)
|
||||
- execSync(`wsl -d ${distro} -e ${command}`) (Windows only)
|
||||
```
|
||||
|
||||
### 安全围栏
|
||||
- `docker_exec` / `wsl_exec` 归入 DANGEROUS_TOOLS
|
||||
- `docker rm`, `docker rmi`, `docker system prune` 归入 CRITICAL_PATTERNS
|
||||
|
||||
### UI 扩展
|
||||
- 设置面板工具权限 tab 新增 toggle:
|
||||
```
|
||||
Docker / WSL 工具 — 允许管理容器和 WSL 环境
|
||||
```
|
||||
|
||||
### 内置技能卡片
|
||||
```js
|
||||
{
|
||||
id: 'detect-docker-openclaw',
|
||||
icon: '🐳',
|
||||
name: '检测 Docker/WSL 中的 OpenClaw',
|
||||
desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装',
|
||||
tools: ['docker'],
|
||||
prompt: `请帮我检查 Docker 和 WSL 中是否安装了 OpenClaw。
|
||||
1. 调用 get_system_info 判断操作系统
|
||||
2. 用 docker_list 列出所有容器,过滤包含 openclaw/gateway 的
|
||||
3. 如果是 Windows,用 wsl_list 列出 WSL 发行版
|
||||
4. 对每个 WSL 发行版,用 wsl_exec 执行 "which openclaw" 检测
|
||||
5. 汇总发现的 OpenClaw 实例及其状态`
|
||||
}
|
||||
```
|
||||
|
||||
### 优先级:🔴 高(解决用户最常见困惑)
|
||||
### 工时估算:1-2 天
|
||||
|
||||
---
|
||||
|
||||
## 模块二:联网搜索工具
|
||||
|
||||
### 场景
|
||||
- 用户遇到不常见的错误,AI 知识库可能没有
|
||||
- 搜索 GitHub Issues、文档、Stack Overflow 找到解决方案
|
||||
- 查找最新版本信息、API 文档等
|
||||
|
||||
### 工具定义
|
||||
|
||||
| 工具名 | 描述 | 参数 | 危险等级 |
|
||||
|--------|------|------|----------|
|
||||
| `web_search` | 联网搜索关键词 | `query`, `max_results?` | 安全 |
|
||||
| `fetch_url` | 抓取网页内容 | `url` | 安全 |
|
||||
|
||||
### 后端实现方案(3 选 1)
|
||||
|
||||
#### 方案 A:DuckDuckGo Instant Answer API(推荐,免费无 Key)
|
||||
```js
|
||||
// 搜索
|
||||
const resp = await fetch(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`)
|
||||
// 但 Instant Answer 只返回摘要,不返回搜索结果列表
|
||||
|
||||
// 实际搜索需要用 DuckDuckGo HTML 页面解析或第三方库
|
||||
```
|
||||
|
||||
#### 方案 B:SearXNG 代理(自托管,最灵活)
|
||||
```js
|
||||
// 部署一个 SearXNG 实例,或者用公共实例
|
||||
const resp = await fetch(`https://searx.example.com/search?q=${query}&format=json`)
|
||||
```
|
||||
|
||||
#### 方案 C:Jina Reader API(推荐搭配使用,免费)
|
||||
```js
|
||||
// 将任意 URL 转为纯文本/Markdown
|
||||
const resp = await fetch(`https://r.jina.ai/${targetUrl}`)
|
||||
const text = await resp.text()
|
||||
```
|
||||
|
||||
### 推荐组合
|
||||
- **搜索**:使用 DuckDuckGo 的 `html.duckduckgo.com/html/?q=xxx` 页面解析结果
|
||||
- **内容抓取**:使用 Jina Reader `r.jina.ai/URL` 获取纯文本
|
||||
- 两者都 **免费无 Key**,无需用户配置
|
||||
|
||||
### 系统提示词补充
|
||||
```
|
||||
## web_search 使用指南
|
||||
当你无法确定答案或需要最新信息时,可以使用 web_search 搜索互联网。
|
||||
搜索后,如果需要更多内容,可以用 fetch_url 抓取具体页面。
|
||||
搜索技巧:
|
||||
- 加 site:github.com 搜索 GitHub
|
||||
- 加 site:stackoverflow.com 搜索 StackOverflow
|
||||
- 搜索错误信息时,用引号包裹关键错误文本
|
||||
```
|
||||
|
||||
### 安全围栏
|
||||
- 搜索和抓取不涉及破坏性操作,不归入 DANGEROUS_TOOLS
|
||||
- 但需要网络请求,添加超时保护(10 秒)
|
||||
- URL 抓取限制最大内容长度(100KB → 截断)
|
||||
|
||||
### UI 扩展
|
||||
```
|
||||
联网搜索 — 允许搜索互联网和抓取网页内容(需联网)
|
||||
```
|
||||
|
||||
### 优先级:🔴 高(大幅提升问题解决能力)
|
||||
### 工时估算:0.5-1 天
|
||||
|
||||
---
|
||||
|
||||
## 模块三:SSH 远程管理工具
|
||||
|
||||
### 场景
|
||||
- 用户的 OpenClaw 部署在远程服务器上
|
||||
- 帮用户远程安装、配置、排查 OpenClaw
|
||||
- 远程查看日志、重启服务、修改配置
|
||||
|
||||
### 工具定义
|
||||
|
||||
| 工具名 | 描述 | 参数 | 危险等级 |
|
||||
|--------|------|------|----------|
|
||||
| `ssh_exec` | 在远程服务器执行命令 | `connection_id`, `command` | ⚠️ 危险 |
|
||||
| `ssh_read_file` | 读取远程文件 | `connection_id`, `path` | 安全 |
|
||||
| `ssh_write_file` | 写入远程文件 | `connection_id`, `path`, `content` | ⚠️ 危险 |
|
||||
|
||||
### 配置数据结构
|
||||
|
||||
```js
|
||||
_config.sshConnections = [
|
||||
{
|
||||
id: 'my-server',
|
||||
name: '生产服务器',
|
||||
host: '192.168.1.100',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
authType: 'key', // 'key' | 'password'
|
||||
keyPath: '~/.ssh/id_rsa',
|
||||
// password 不存储在 localStorage,每次询问或用 keytar 安全存储
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
```
|
||||
Tauri (Rust):
|
||||
- 使用 ssh2 crate 或调用系统 ssh CLI
|
||||
- ssh_exec → Command::new("ssh").args(["-p", port, "user@host", command])
|
||||
- ssh_read_file → ssh + cat
|
||||
- ssh_write_file → 通过 stdin pipe 写入
|
||||
|
||||
dev-api.js (Web):
|
||||
- 使用 node-ssh 或 ssh2 npm 包
|
||||
- 或者直接调用 ssh CLI
|
||||
```
|
||||
|
||||
### 设置 UI:新增 tab「远程连接」
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 模型配置 │ 工具权限 │ 远程连接 │ 助手人设 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─ 生产服务器 ──────────────── [编辑] [删] │
|
||||
│ │ root@192.168.1.100:22 (密钥认证) │
|
||||
│ └──────────────────────────────────────────│
|
||||
│ │
|
||||
│ ┌─ 测试服务器 ──────────────── [编辑] [删] │
|
||||
│ │ admin@10.0.0.5:22 (密码认证) │
|
||||
│ └──────────────────────────────────────────│
|
||||
│ │
|
||||
│ [+ 添加连接] │
|
||||
│ │
|
||||
│ 提示:推荐使用 SSH 密钥认证。 │
|
||||
│ 生成密钥:ssh-keygen -t ed25519 │
|
||||
│ 复制公钥:ssh-copy-id user@host │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 安全围栏
|
||||
- `ssh_exec`, `ssh_write_file` 归入 DANGEROUS_TOOLS
|
||||
- 密码认证:每次执行时用 ask_user 确认,或使用系统密钥链
|
||||
- SSH 密钥路径验证:检查文件是否存在
|
||||
- 关键命令(rm -rf, reboot 等)在远程同样走 CRITICAL_PATTERNS
|
||||
|
||||
### 系统提示词补充
|
||||
```
|
||||
## SSH 远程管理
|
||||
用户可能配置了远程服务器连接。当操作远程服务器时:
|
||||
- 先用 ask_user 确认要操作哪个连接
|
||||
- 远程命令比本地更谨慎,优先使用只读操作
|
||||
- 修改配置前先备份(cp xxx xxx.bak)
|
||||
```
|
||||
|
||||
### 内置技能卡片
|
||||
```js
|
||||
{
|
||||
id: 'remote-manage',
|
||||
icon: '🌐',
|
||||
name: '远程管理 OpenClaw',
|
||||
desc: '通过 SSH 连接远程服务器,管理 OpenClaw',
|
||||
tools: ['ssh', 'fileOps'],
|
||||
prompt: `请帮我管理远程服务器上的 OpenClaw。
|
||||
1. 获取系统信息,列出已配置的 SSH 连接
|
||||
2. 用 ask_user 让我选择要操作的服务器
|
||||
3. 用 ssh_exec 检查远程 OpenClaw 状态
|
||||
4. 检查 Gateway 进程和端口
|
||||
5. 读取远程配置和日志
|
||||
6. 汇总远程 OpenClaw 状态报告`
|
||||
}
|
||||
```
|
||||
|
||||
### 优先级:🟡 中(用户量较少但价值极高)
|
||||
### 工时估算:2-3 天
|
||||
|
||||
---
|
||||
|
||||
## 模块四:知识库 + 灵魂移植(借尸还魂 🔥)
|
||||
|
||||
### 核心理念
|
||||
|
||||
OpenClaw 的 Agent 有一套完整的**身份系统**,由工作区引导文件定义:
|
||||
|
||||
```
|
||||
~/.openclaw/workspace/ ← Agent 的"灵魂"所在
|
||||
├── AGENTS.md ← 操作指令、规则、记忆管理方式
|
||||
├── SOUL.md ← 人设、边界、语气("Who You Are")
|
||||
├── IDENTITY.md ← 名称、物种、风格、表情符号、头像
|
||||
├── USER.md ← 用户档案(名字、称呼、时区、偏好)
|
||||
├── TOOLS.md ← 工具本地笔记(SSH 配置、设备名等)
|
||||
├── HEARTBEAT.md ← 心跳任务清单
|
||||
├── MEMORY.md ← 精选长期记忆(仅主会话加载)
|
||||
└── memory/ ← 每日记忆日志
|
||||
├── 2026-03-04-1609.md
|
||||
└── ...
|
||||
|
||||
~/.openclaw/agents/<agentId>/agent/ ← Agent 的运行时状态
|
||||
├── models.json ← 模型提供商配置(baseUrl + apiKey + models)
|
||||
├── auth-profiles.json ← 认证配置文件
|
||||
└── auth.json
|
||||
```
|
||||
|
||||
**"借尸还魂"不是复用知识库,而是完整接管 Agent 的灵魂**——
|
||||
ClawPanel 的 AI 助手直接读取这些文件,像 OpenClaw 一样把它们注入 system prompt,
|
||||
从而变成那个 Agent:有他的名字、他的性格、他的记忆、他认识的用户。
|
||||
|
||||
### 4A:灵魂移植(Agent Identity Takeover)
|
||||
|
||||
#### 工作流程
|
||||
|
||||
1. **扫描** `~/.openclaw/workspace/` 和 `~/.openclaw/agents/` 目录
|
||||
2. **发现** 所有可用的 Agent 身份(main、test 等)
|
||||
3. **用户选择** 要附身的 Agent
|
||||
4. **读取** 该 Agent 的全部引导文件:
|
||||
- `SOUL.md` → 注入为人设(替换 ClawPanel 助手的默认人设)
|
||||
- `IDENTITY.md` → 提取名称/表情/风格(替换助手名称和性格描述)
|
||||
- `USER.md` → 注入用户上下文(知道用户叫什么、偏好什么)
|
||||
- `AGENTS.md` → 注入操作规则(Agent 的行为准则)
|
||||
- `TOOLS.md` → 注入工具笔记
|
||||
- `MEMORY.md` → 注入长期记忆
|
||||
- `memory/` → 注入最近的每日记忆(最近 3 天)
|
||||
5. **注入** 到 `buildSystemPrompt()` 中,完全替代默认人设
|
||||
|
||||
#### 实现
|
||||
|
||||
```js
|
||||
// 新增配置项
|
||||
_config.soulSource = null // null = 使用 ClawPanel 默认 | 'openclaw:main' | 'openclaw:test' | 'custom'
|
||||
_config.soulCache = null // 缓存读取的灵魂文件内容
|
||||
|
||||
// buildSystemPrompt 改造
|
||||
function buildSystemPrompt() {
|
||||
if (_config.soulSource?.startsWith('openclaw:')) {
|
||||
// 借尸还魂模式:使用 OpenClaw Agent 的灵魂
|
||||
return buildOpenClawSoulPrompt()
|
||||
}
|
||||
// 默认模式:使用 ClawPanel 自带的系统提示词
|
||||
return buildDefaultPrompt()
|
||||
}
|
||||
|
||||
function buildOpenClawSoulPrompt() {
|
||||
const soul = _config.soulCache
|
||||
if (!soul) return buildDefaultPrompt() // fallback
|
||||
|
||||
let prompt = ''
|
||||
|
||||
// 1. 身份注入
|
||||
if (soul.identity) {
|
||||
prompt += `# Identity\n${soul.identity}\n\n`
|
||||
}
|
||||
|
||||
// 2. 灵魂注入(人设、边界、语气)
|
||||
if (soul.soul) {
|
||||
prompt += `# Soul\n${soul.soul}\n\n`
|
||||
}
|
||||
|
||||
// 3. 用户上下文
|
||||
if (soul.user) {
|
||||
prompt += `# User\n${soul.user}\n\n`
|
||||
}
|
||||
|
||||
// 4. 操作规则
|
||||
if (soul.agents) {
|
||||
prompt += `# Operating Instructions\n${soul.agents}\n\n`
|
||||
}
|
||||
|
||||
// 5. 工具笔记
|
||||
if (soul.tools) {
|
||||
prompt += `# Tool Notes\n${soul.tools}\n\n`
|
||||
}
|
||||
|
||||
// 6. 长期记忆
|
||||
if (soul.memory) {
|
||||
prompt += `# Long-term Memory\n${soul.memory}\n\n`
|
||||
}
|
||||
|
||||
// 7. 最近的每日记忆
|
||||
if (soul.recentMemories?.length) {
|
||||
prompt += `# Recent Memory\n`
|
||||
for (const m of soul.recentMemories) {
|
||||
prompt += `## ${m.date}\n${m.content}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 追加 ClawPanel 特有的工具说明(保持工具能力)
|
||||
prompt += buildToolInstructions()
|
||||
|
||||
return prompt
|
||||
}
|
||||
```
|
||||
|
||||
#### 灵魂加载函数
|
||||
|
||||
```js
|
||||
async function loadOpenClawSoul(agentId = 'main') {
|
||||
const home = await getHomeDir()
|
||||
const ws = `${home}/.openclaw/workspace` // 工作区是全局的,不按 agentId 分
|
||||
|
||||
const readSafe = async (path) => {
|
||||
try { return await api.assistantReadFile(path) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
const soul = {
|
||||
identity: await readSafe(`${ws}/IDENTITY.md`),
|
||||
soul: await readSafe(`${ws}/SOUL.md`),
|
||||
user: await readSafe(`${ws}/USER.md`),
|
||||
agents: await readSafe(`${ws}/AGENTS.md`),
|
||||
tools: await readSafe(`${ws}/TOOLS.md`),
|
||||
memory: await readSafe(`${ws}/MEMORY.md`),
|
||||
recentMemories: [],
|
||||
}
|
||||
|
||||
// 读取最近 3 天的每日记忆
|
||||
try {
|
||||
const memDir = await api.assistantListDir(`${ws}/memory`)
|
||||
const files = memDir.split('\n').filter(f => f.match(/\d{4}-\d{2}-\d{2}/))
|
||||
const recent = files.sort().slice(-3)
|
||||
for (const f of recent) {
|
||||
const content = await readSafe(`${ws}/memory/${f.trim()}`)
|
||||
if (content) soul.recentMemories.push({ date: f.trim(), content })
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return soul
|
||||
}
|
||||
```
|
||||
|
||||
#### UI:设置面板「助手人设」Tab 改造
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 人设 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 身份来源 │
|
||||
│ ┌──────────────────────────────────────────│
|
||||
│ │ ● ClawPanel 默认人设 │ ← 当前默认
|
||||
│ │ ○ OpenClaw Agent 身份(借尸还魂) │ ← 新增
|
||||
│ │ ○ 自定义人设 │
|
||||
│ └──────────────────────────────────────────│
|
||||
│ │
|
||||
│ ─── 当选择「OpenClaw Agent」时显示 ──── │
|
||||
│ │
|
||||
│ 选择 Agent: [main ▼] │
|
||||
│ │
|
||||
│ 📜 灵魂文件预览 │
|
||||
│ ┌──────────────────────────────────────────│
|
||||
│ │ SOUL.md ✅ 已加载 (1.6KB) │
|
||||
│ │ IDENTITY.md ✅ 已加载 (636B) │
|
||||
│ │ USER.md ✅ 已加载 (237B) │
|
||||
│ │ AGENTS.md ✅ 已加载 (7.8KB) │
|
||||
│ │ TOOLS.md ✅ 已加载 (860B) │
|
||||
│ │ MEMORY.md ❌ 未找到 │
|
||||
│ │ memory/ 📝 2 个日志文件 │
|
||||
│ └──────────────────────────────────────────│
|
||||
│ │
|
||||
│ [👻 附身!] [🔄 刷新] │
|
||||
│ │
|
||||
│ ⚠️ 附身后,助手将使用该 Agent 的人格、 │
|
||||
│ 记忆和用户偏好。可随时切回默认。 │
|
||||
│ │
|
||||
│ ─── 当选择「ClawPanel 默认」时显示 ──── │
|
||||
│ │
|
||||
│ 助手名称: [晴辰助手 ] │
|
||||
│ 助手性格: [________________________] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 附身后的效果
|
||||
|
||||
| 维度 | 默认模式 | 附身模式 |
|
||||
|------|----------|----------|
|
||||
| 名称 | "晴辰助手" | IDENTITY.md 中的名称 |
|
||||
| 性格 | 简洁专业 | SOUL.md 定义的风格 |
|
||||
| 称呼用户 | "你" | USER.md 中的称呼(如"爸爸") |
|
||||
| 行为规则 | ClawPanel 内置 | AGENTS.md 的规则体系 |
|
||||
| 记忆 | 无 | MEMORY.md + 每日记忆 |
|
||||
| 工具知识 | ClawPanel 内置 | TOOLS.md 的本地笔记 |
|
||||
| 工具能力 | 保持不变 | 保持 ClawPanel 的工具 |
|
||||
|
||||
**关键设计**:附身只替换"灵魂"(system prompt),**工具能力保持 ClawPanel 的**。
|
||||
因为 OpenClaw 的工具(exec/read/edit/write)和 ClawPanel 的工具本质相同,
|
||||
但 ClawPanel 有独有的 docker/ssh/搜索等扩展工具,这些要保留。
|
||||
|
||||
### 4B:自定义知识库
|
||||
|
||||
在灵魂移植之外,仍然支持用户上传额外的知识文档:
|
||||
|
||||
#### 数据存储
|
||||
```
|
||||
~/.openclaw/clawpanel-kb/
|
||||
├── index.json # 知识库索引
|
||||
├── docs/
|
||||
│ ├── api-guide.md # 用户上传的文档
|
||||
│ ├── faq.md
|
||||
│ └── deploy-notes.txt
|
||||
└── chunks/ # 分块索引(可选,用于大文档)
|
||||
└── ...
|
||||
```
|
||||
|
||||
#### 实现方案
|
||||
|
||||
**V1(简单方案)**:
|
||||
- 小文档(<8KB)直接全文注入 system prompt 尾部
|
||||
- 大文档做关键词搜索(正则匹配 + 上下文窗口)
|
||||
- 总注入 token 上限:4000 tokens
|
||||
- 知识库和灵魂移植可叠加使用
|
||||
|
||||
**V2(进阶方案)**:
|
||||
- embedding 语义搜索
|
||||
- `search_knowledge` 工具让 AI 按需检索
|
||||
|
||||
### 优先级:<E7BAA7> 高(灵魂移植是杀手级差异化功能)
|
||||
### 工时估算:灵魂移植 1-2 天,自定义知识库 V1 额外 1 天
|
||||
|
||||
---
|
||||
|
||||
## 模块五:模型配置自动导入
|
||||
|
||||
### 场景
|
||||
- 用户已安装 OpenClaw 并配置了模型
|
||||
- ClawPanel AI 助手需要单独配置模型(目前手动填写)
|
||||
- 一键从 OpenClaw 配置导入,省去重复配置
|
||||
|
||||
### 实现
|
||||
|
||||
#### 数据来源(两个层级)
|
||||
|
||||
**层级 1:全局配置** `~/.openclaw/openclaw.json`
|
||||
```json
|
||||
{
|
||||
"models": {
|
||||
"providers": {
|
||||
"shengsuanyun": {
|
||||
"baseUrl": "http://127.0.0.1:8082/v1",
|
||||
"apiKey": "sk-xxx",
|
||||
"api": "openai-completions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**层级 2:Agent 模型注册表** `~/.openclaw/agents/<agentId>/agent/models.json`
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"baseUrl": "http://127.0.0.1:8082/v1",
|
||||
"apiKey": "sk-eB3ybVNFvqB4fGrTUp3F8Lq16QxF7tut",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{ "id": "gpt-5.4", "name": "gpt-5.4", "contextWindow": 200000, "maxTokens": 8192 },
|
||||
{ "id": "gpt-5.2-codex", "name": "gpt-5.2-codex", ... }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**推荐优先读取 Agent 的 models.json**——它有完整的 baseUrl + apiKey + models 列表,
|
||||
一键就能填充 ClawPanel 助手的配置。
|
||||
|
||||
#### 读取逻辑
|
||||
```js
|
||||
async function discoverOpenClawModels() {
|
||||
const home = await getHomeDir()
|
||||
const results = []
|
||||
|
||||
// 1. 扫描所有 Agent 的 models.json
|
||||
try {
|
||||
const agents = await api.assistantListDir(`${home}/.openclaw/agents`)
|
||||
for (const agentId of agents.split('\n').map(s => s.trim()).filter(Boolean)) {
|
||||
try {
|
||||
const raw = await api.assistantReadFile(`${home}/.openclaw/agents/${agentId}/agent/models.json`)
|
||||
const data = JSON.parse(raw)
|
||||
for (const [providerId, provider] of Object.entries(data.providers || {})) {
|
||||
results.push({
|
||||
source: `Agent: ${agentId}`,
|
||||
providerId,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
apiType: provider.api === 'openai-completions' ? 'openai' : provider.api,
|
||||
models: (provider.models || []).map(m => m.id || m.name),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 2. 读取全局 openclaw.json 作为补充
|
||||
try {
|
||||
const raw = await api.assistantReadFile(`${home}/.openclaw/openclaw.json`)
|
||||
const config = JSON.parse(raw)
|
||||
for (const [providerId, provider] of Object.entries(config.models?.providers || {})) {
|
||||
// 去重:如果 Agent models.json 已有相同 providerId,跳过
|
||||
if (!results.find(r => r.providerId === providerId)) {
|
||||
results.push({
|
||||
source: '全局配置',
|
||||
providerId,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
apiType: 'openai',
|
||||
models: [], // 全局配置没有 models 列表
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
#### UI:模型配置 tab 新增「导入」按钮
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ API Base URL API 类型 │
|
||||
│ [________________________] [OpenAI 兼容 ▼] │
|
||||
│ │
|
||||
│ API Key [测试] [拉取] [📥 导入] │ ← 新增「导入」按钮
|
||||
│ [________________________] │
|
||||
│ │
|
||||
│ 模型 温度 │
|
||||
│ [________________________] [0.7] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
点击「📥 导入」弹出选择面板:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 从 OpenClaw 导入模型配置 │
|
||||
│ │
|
||||
│ 检测到以下已配置的服务商: │
|
||||
│ │
|
||||
│ ○ OpenAI │
|
||||
│ https://api.openai.com/v1 │
|
||||
│ 模型: gpt-4o, gpt-4o-mini │
|
||||
│ │
|
||||
│ ○ DeepSeek │
|
||||
│ https://api.deepseek.com │
|
||||
│ 模型: deepseek-chat, deepseek-reasoner │
|
||||
│ │
|
||||
│ ○ 本地 Ollama │
|
||||
│ http://127.0.0.1:11434/v1 │
|
||||
│ 模型: qwen2.5:7b │
|
||||
│ │
|
||||
│ 选择一个服务商,自动填充配置。 │
|
||||
│ [取消] [导入] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 后端
|
||||
|
||||
```
|
||||
Tauri: 已有 read_openclaw_config 命令
|
||||
dev-api.js: 已有 read_config handler
|
||||
|
||||
// 只需在前端加一个读取+解析+填充的逻辑
|
||||
```
|
||||
|
||||
### 优先级:🔴 高(零成本,纯前端,极大提升体验)
|
||||
### 工时估算:0.5 天
|
||||
|
||||
---
|
||||
|
||||
## 实施路线图
|
||||
|
||||
### Phase 1:快速见效(1-2 天)
|
||||
| 序号 | 功能 | 工时 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 1 | **模型配置自动导入** | 0.5d | 读 Agent models.json → 一键填充,纯前端零风险 |
|
||||
| 2 | **联网搜索工具** | 0.5-1d | DuckDuckGo + Jina,免费无 Key |
|
||||
| 3 | **灵魂移植(借尸还魂)** | 1-2d | 杀手级差异化——读 SOUL/IDENTITY/USER/AGENTS/MEMORY → 变身 |
|
||||
|
||||
### Phase 2:核心扩展(2-3 天)
|
||||
| 序号 | 功能 | 工时 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 4 | **Docker/WSL 工具** | 1-2d | 解决用户最常见的安装困惑 |
|
||||
| 5 | **自定义知识库 V1** | 1d | 用户上传 md/txt → 注入 prompt |
|
||||
|
||||
### Phase 3:高级功能(3-5 天)
|
||||
| 序号 | 功能 | 工时 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 6 | **SSH 远程管理** | 2-3d | 价值最高但复杂度也最高 |
|
||||
| 7 | **知识库 V2(语义搜索)** | 3-5d | 依赖 embedding API |
|
||||
|
||||
---
|
||||
|
||||
## 设置面板 Tab 规划
|
||||
|
||||
当前 3 个 Tab → 扩展为 5 个 Tab:
|
||||
|
||||
```
|
||||
模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 助手人设
|
||||
```
|
||||
|
||||
### 工具权限 Tab 最终形态
|
||||
|
||||
```
|
||||
基础工具
|
||||
☑ 终端工具 — 允许执行 Shell 命令
|
||||
☑ 文件工具 — 允许读写文件和浏览目录
|
||||
|
||||
扩展工具
|
||||
☐ Docker/WSL — 允许管理容器和 WSL 环境
|
||||
☐ 联网搜索 — 允许搜索互联网和抓取网页
|
||||
☐ SSH 远程 — 允许连接远程服务器(需先配置连接)
|
||||
☐ 知识库 — 允许检索知识库内容
|
||||
|
||||
ℹ️ 进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术注意事项
|
||||
|
||||
### 1. Token 预算管理
|
||||
灵魂移植 + 知识库注入会占用 context window,需要精细管理:
|
||||
|
||||
| 组件 | 预算 | 说明 |
|
||||
|------|------|------|
|
||||
| ClawPanel 基础 prompt | ~2000 tokens | 产品介绍、工具指南、技能卡片 |
|
||||
| SOUL.md | ~500 tokens | 人设通常简短 |
|
||||
| IDENTITY.md | ~200 tokens | 名称/风格 |
|
||||
| USER.md | ~200 tokens | 用户档案 |
|
||||
| AGENTS.md | ~3000 tokens | 操作规则(最大,可截断) |
|
||||
| TOOLS.md | ~300 tokens | 工具笔记 |
|
||||
| MEMORY.md | ~2000 tokens | 长期记忆(截断保留最近部分) |
|
||||
| 每日记忆 (3天) | ~1500 tokens | 自动截断 |
|
||||
| 自定义知识库 | ~4000 tokens | 用户上传文档 |
|
||||
| 搜索结果 | ~2000 tokens | web_search 返回内容 |
|
||||
| **总计上限** | **~16000 tokens** | 留足空间给对话历史 |
|
||||
|
||||
策略:
|
||||
- AGENTS.md 超过 3000 tokens 时截断尾部,保留前面的核心规则
|
||||
- MEMORY.md 超过 2000 tokens 时只保留最后 2000 tokens
|
||||
- 每日记忆超过 500 tokens/天时截断
|
||||
- 灵魂文件加载时计算总 token 并在 UI 中显示
|
||||
|
||||
### 2. 跨平台兼容
|
||||
- Docker CLI 在 Windows/Mac/Linux 都可用
|
||||
- WSL 仅 Windows
|
||||
- SSH 密钥路径:Windows 用 `%USERPROFILE%\.ssh\`,Mac/Linux 用 `~/.ssh/`
|
||||
|
||||
### 3. 安全存储
|
||||
- SSH 密码/API Key:
|
||||
- Tauri 模式:使用 keytar 或 tauri-plugin-store 加密存储
|
||||
- Web 模式:仅支持密钥认证(不存储密码)
|
||||
- 知识库文件:存储在 `~/.openclaw/` 下,与用户数据同目录
|
||||
|
||||
### 4. 工具发现
|
||||
AI 模型需要知道哪些工具可用。当前已在 `buildSystemPrompt()` 中列出技能卡片。
|
||||
新增工具后,需要在系统提示词中补充使用指南(类似现有的 `ask_user` 指南)。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更预估
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/pages/assistant.js` | TOOL_DEFS 新增 4 类 · executeTool 新增 case · getEnabledTools 新增分支 · 设置面板 UI · 模型导入弹窗 |
|
||||
| `src-tauri/src/commands/assistant.rs` | 新增 Rust 命令:docker_*, wsl_*, ssh_*, web_search, fetch_url |
|
||||
| `scripts/dev-api.js` | 新增 Web 模式 handler:同上 |
|
||||
| `src/style/assistant.css` | 知识库管理 UI · SSH 连接管理 UI · 导入弹窗样式 |
|
||||
| `src/pages/assistant.js` (prompt) | 系统提示词新增各工具使用指南 |
|
||||
@@ -1,494 +0,0 @@
|
||||
# ClawPanel Docker 多实例管理 — 技术规划
|
||||
|
||||
> 版本: v1.0 | 日期: 2026-03-08
|
||||
|
||||
## 1. 问题分析
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
ClawPanel 当前架构是 **单实例管理**:
|
||||
|
||||
```
|
||||
浏览器 → ClawPanel 前端
|
||||
│
|
||||
├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件
|
||||
├── /ws → 代理到本机 Gateway:18789 (WebSocket)
|
||||
└── 静态文件 → dist/
|
||||
```
|
||||
|
||||
**所有页面**(模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是:
|
||||
- 本机文件系统上的 `~/.openclaw/openclaw.json`
|
||||
- 本机运行的 Gateway 进程(端口 18789)
|
||||
|
||||
### 1.2 Phase 1 已完成
|
||||
|
||||
Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API):
|
||||
- 启动/停止/重启/删除容器
|
||||
- 部署新容器(端口映射、数据卷、环境变量)
|
||||
- 查看容器日志
|
||||
- 多节点管理(本机 + 远程 Docker 主机)
|
||||
|
||||
### 1.3 缺口
|
||||
|
||||
Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**:
|
||||
- 无法配置某个容器内的模型
|
||||
- 无法查看某个容器内的 Gateway 日志
|
||||
- 无法管理某个容器内的 Agent
|
||||
- 聊天功能只连本机 Gateway
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 核心思路:API 代理 + 实例切换
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ClawPanel 前端 │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ 实例切换器: [ ● 本机 ▼ ] │ │
|
||||
│ │ [ ○ prod-server (Docker) ] │ │
|
||||
│ │ [ ○ dev-box (远程) ] │ │
|
||||
│ │ [ + 添加实例 ] │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 现有页面(模型/Agent/Gateway/日志/聊天...) │
|
||||
│ │ │
|
||||
│ │ api.readOpenclawConfig() │
|
||||
│ │ api.listAgents() │
|
||||
│ ▼ │
|
||||
│ tauri-api.js → webInvoke('read_openclaw_config') │
|
||||
│ │ │
|
||||
│ 自动附带 instanceId │
|
||||
└──────────────────┼───────────────────────────────┘
|
||||
▼
|
||||
dev-api.js (本机后端)
|
||||
│
|
||||
┌────────┼────────┐
|
||||
▼ ▼ ▼
|
||||
本机文件 代理转发 代理转发
|
||||
~/.openclaw ↓ ↓
|
||||
实例 A 实例 B
|
||||
http://host http://192.168.1.100
|
||||
:18790 :1420
|
||||
/__api/* /__api/*
|
||||
```
|
||||
|
||||
**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。**
|
||||
因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。
|
||||
|
||||
### 2.2 WebSocket 连接
|
||||
|
||||
```
|
||||
切换实例时:
|
||||
wsClient.disconnect() ← 断开旧连接
|
||||
wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway
|
||||
```
|
||||
|
||||
WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。
|
||||
|
||||
### 2.3 自动组网流程
|
||||
|
||||
部署新容器时自动完成:
|
||||
|
||||
```
|
||||
用户点击「部署容器」
|
||||
│
|
||||
├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789)
|
||||
├─ 2. 启动容器,等待健康检查通过
|
||||
├─ 3. 探测容器 Panel 端点:GET http://hostIP:hostPort/__api/check_installation
|
||||
├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json
|
||||
└─ 5. 前端自动刷新实例列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
### 3.1 实例注册表
|
||||
|
||||
文件位置:`~/.openclaw/instances.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"activeId": "local",
|
||||
"instances": [
|
||||
{
|
||||
"id": "local",
|
||||
"name": "本机",
|
||||
"type": "local",
|
||||
"endpoint": null,
|
||||
"gatewayPort": 18789,
|
||||
"addedAt": 1741420800,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "docker-abc123",
|
||||
"name": "openclaw-prod",
|
||||
"type": "docker",
|
||||
"endpoint": "http://127.0.0.1:18790",
|
||||
"gatewayPort": 18789,
|
||||
"containerId": "abc123def456",
|
||||
"nodeId": "local",
|
||||
"addedAt": 1741420900,
|
||||
"note": "生产环境"
|
||||
},
|
||||
{
|
||||
"id": "remote-1",
|
||||
"name": "办公室服务器",
|
||||
"type": "remote",
|
||||
"endpoint": "http://192.168.1.100:1420",
|
||||
"gatewayPort": 18789,
|
||||
"addedAt": 1741421000,
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**三种实例类型:**
|
||||
|
||||
| type | 说明 | 来源 |
|
||||
|------|------|------|
|
||||
| `local` | 本机 OpenClaw | 始终存在,不可删除 |
|
||||
| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 |
|
||||
| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 |
|
||||
|
||||
### 3.2 实例状态(运行时,不持久化)
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'docker-abc123',
|
||||
online: true, // 健康检查结果
|
||||
version: '2026.3.5', // OpenClaw 版本
|
||||
gatewayRunning: true, // Gateway 状态
|
||||
lastCheck: 1741420999, // 上次检查时间
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 改动清单
|
||||
|
||||
### 4.1 后端 dev-api.js
|
||||
|
||||
#### 4.1.1 实例注册表管理(新增)
|
||||
|
||||
```
|
||||
新增 handlers:
|
||||
instance_list → 读取 instances.json
|
||||
instance_add → 添加实例(手动或自动)
|
||||
instance_remove → 删除实例
|
||||
instance_set_active → 切换活跃实例
|
||||
instance_health_check → 健康检查单个实例
|
||||
instance_health_all → 批量健康检查
|
||||
```
|
||||
|
||||
#### 4.1.2 API 代理转发(核心改动)
|
||||
|
||||
改造 `_apiMiddleware`:
|
||||
|
||||
```js
|
||||
// 伪代码
|
||||
async function _apiMiddleware(req, res, next) {
|
||||
if (!req.url?.startsWith('/__api/')) return next()
|
||||
|
||||
const cmd = extractCmd(req.url)
|
||||
const body = await readBody(req)
|
||||
|
||||
// 实例管理命令 → 始终本机处理
|
||||
if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) {
|
||||
return handleLocally(cmd, body, res)
|
||||
}
|
||||
|
||||
// 获取当前活跃实例
|
||||
const active = getActiveInstance()
|
||||
|
||||
if (active.type === 'local') {
|
||||
// 本机 → 直接处理(现有逻辑不变)
|
||||
return handleLocally(cmd, body, res)
|
||||
}
|
||||
|
||||
// 远程/Docker 实例 → 代理转发
|
||||
return proxyToInstance(active, cmd, body, res)
|
||||
}
|
||||
```
|
||||
|
||||
**始终在本机处理的命令(ALWAYS_LOCAL):**
|
||||
- `instance_*` — 实例管理本身
|
||||
- `docker_*` — Docker 容器管理
|
||||
- `auth_*` — 认证
|
||||
- `read_panel_config` / `write_panel_config` — 本地面板配置
|
||||
- `assistant_*` — AI 助手(操作本机文件系统)
|
||||
|
||||
**通过代理转发的命令:**
|
||||
- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置
|
||||
- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务
|
||||
- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent
|
||||
- `read_log_tail` / `search_log` — 目标实例的日志
|
||||
- `get_version_info` / `upgrade_openclaw` — 目标实例的版本
|
||||
- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件
|
||||
- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置
|
||||
- 等其他 OpenClaw 相关命令
|
||||
|
||||
#### 4.1.3 代理转发实现
|
||||
|
||||
```js
|
||||
async function proxyToInstance(instance, cmd, body, res) {
|
||||
const url = `${instance.endpoint}/__api/${cmd}`
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.text()
|
||||
res.writeHead(resp.status, { 'Content-Type': 'application/json' })
|
||||
res.end(data)
|
||||
} catch (e) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` }))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.1.4 Docker 部署自动注册
|
||||
|
||||
修改 `docker_create_container` handler:
|
||||
- 容器创建并启动后,自动等待健康检查
|
||||
- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证
|
||||
- 健康检查通过后自动写入 `instances.json`
|
||||
- 返回结果包含 `instanceId`
|
||||
|
||||
### 4.2 前端 tauri-api.js
|
||||
|
||||
#### 4.2.1 新增实例管理 API
|
||||
|
||||
```js
|
||||
// 实例管理
|
||||
instanceList: () => cachedInvoke('instance_list', {}, 10000),
|
||||
instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) },
|
||||
instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) },
|
||||
instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) },
|
||||
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
|
||||
instanceHealthAll: () => invoke('instance_health_all'),
|
||||
```
|
||||
|
||||
**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。
|
||||
|
||||
#### 4.2.2 无需改动的部分
|
||||
|
||||
现有的 `api.readOpenclawConfig()`、`api.listAgents()` 等方法 **完全不变**。
|
||||
代理逻辑在后端 `_apiMiddleware` 层透明处理。
|
||||
|
||||
### 4.3 前端 app-state.js
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
let _activeInstance = { id: 'local', name: '本机', type: 'local' }
|
||||
let _instanceListeners = []
|
||||
|
||||
export function getActiveInstance() { return _activeInstance }
|
||||
export function onInstanceChange(fn) { ... }
|
||||
|
||||
export async function switchInstance(id) {
|
||||
// 1. 调后端切换
|
||||
await api.instanceSetActive(id)
|
||||
// 2. 更新本地状态
|
||||
_activeInstance = instances.find(i => i.id === id)
|
||||
// 3. 清缓存
|
||||
invalidate() // 清 API 缓存
|
||||
// 4. 断开旧 WebSocket
|
||||
wsClient.disconnect()
|
||||
// 5. 重新检测状态
|
||||
await detectOpenclawStatus()
|
||||
// 6. 连接新实例的 Gateway WebSocket
|
||||
connectToActiveGateway()
|
||||
// 7. 通知所有监听者(侧边栏、页面刷新)
|
||||
_instanceListeners.forEach(fn => fn(_activeInstance))
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 前端 sidebar.js
|
||||
|
||||
在侧边栏顶部 logo 下方添加实例切换器:
|
||||
|
||||
```html
|
||||
<div class="instance-switcher">
|
||||
<button class="instance-current" onclick="toggleDropdown()">
|
||||
<span class="instance-dot online"></span>
|
||||
<span class="instance-name">本机</span>
|
||||
<svg class="chevron">▼</svg>
|
||||
</button>
|
||||
<div class="instance-dropdown">
|
||||
<div class="instance-option active" data-id="local">
|
||||
<span class="instance-dot online"></span> 本机
|
||||
</div>
|
||||
<div class="instance-option" data-id="docker-abc123">
|
||||
<span class="instance-dot online"></span> openclaw-prod
|
||||
<span class="instance-badge">Docker</span>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="instance-option" onclick="addInstance()">
|
||||
<span>+ 添加实例</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4.5 前端 main.js
|
||||
|
||||
`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点:
|
||||
|
||||
```js
|
||||
async function autoConnectWebSocket() {
|
||||
const instance = getActiveInstance()
|
||||
if (instance.type === 'local') {
|
||||
// 本机:读本地配置
|
||||
const config = await api.readOpenclawConfig()
|
||||
const port = config?.gateway?.port || 18789
|
||||
wsClient.connect(`127.0.0.1:${port}`, token)
|
||||
} else {
|
||||
// 远程/Docker:从实例 endpoint 推导 Gateway 地址
|
||||
const config = await api.readOpenclawConfig() // 已通过代理转发
|
||||
const gwPort = config?.gateway?.port || 18789
|
||||
const url = new URL(instance.endpoint)
|
||||
wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 serve.js WebSocket 代理
|
||||
|
||||
WebSocket 代理改为动态目标:
|
||||
|
||||
```js
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
// 从 query 或 header 中获取目标实例
|
||||
const target = resolveWsTarget(req)
|
||||
const conn = net.createConnection(target.port, target.host, () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
### 4.7 docker.js 集群页面
|
||||
|
||||
部署对话框增加"自动注册"逻辑:
|
||||
- 容器创建成功后显示"正在等待实例就绪..."
|
||||
- 健康检查通过后自动出现在实例切换器中
|
||||
- 用户可直接切换到新实例进行管理
|
||||
|
||||
### 4.8 现有页面适配
|
||||
|
||||
| 页面 | 改动 | 说明 |
|
||||
|------|------|------|
|
||||
| dashboard.js | 极小 | 页头显示当前实例名称 |
|
||||
| models.js | 无 | API 透明代理 |
|
||||
| agents.js | 无 | API 透明代理 |
|
||||
| gateway.js | 极小 | 远程实例时隐藏部分本机功能 |
|
||||
| logs.js | 无 | API 透明代理 |
|
||||
| chat.js | 无 | WebSocket 已切换到目标实例 |
|
||||
| chat-debug.js | 无 | API 透明代理 |
|
||||
| memory.js | 无 | API 透明代理 |
|
||||
| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 |
|
||||
| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 |
|
||||
| skills.js | 无 | API 透明代理 |
|
||||
| security.js | 小 | 远程实例的密码管理走代理 |
|
||||
| setup.js | 小 | 远程实例不需要 setup 流程 |
|
||||
| assistant.js | 特殊 | AI 助手始终操作本机(ALWAYS_LOCAL) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
### Step 1: 实例注册表后端(dev-api.js)
|
||||
- `readInstances()` / `saveInstances()` 工具函数
|
||||
- 6 个 handler:`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all`
|
||||
- 预计:~150 行
|
||||
|
||||
### Step 2: API 代理转发(dev-api.js)
|
||||
- 改造 `_apiMiddleware` 添加代理逻辑
|
||||
- `proxyToInstance()` 函数
|
||||
- `ALWAYS_LOCAL` 命令集合
|
||||
- 预计:~80 行
|
||||
|
||||
### Step 3: 前端实例管理 API(tauri-api.js)
|
||||
- 新增 `api.instance*` 方法 + mock 数据
|
||||
- 预计:~40 行
|
||||
|
||||
### Step 4: 前端状态管理(app-state.js)
|
||||
- `_activeInstance` 状态 + `switchInstance()` 函数
|
||||
- 预计:~50 行
|
||||
|
||||
### Step 5: 实例切换器 UI(sidebar.js)
|
||||
- 下拉选择器组件 + CSS
|
||||
- 预计:~100 行 JS + ~80 行 CSS
|
||||
|
||||
### Step 6: WebSocket 动态连接(main.js + serve.js)
|
||||
- 切换实例时重新连接 WebSocket
|
||||
- serve.js WebSocket 代理动态化
|
||||
- 预计:~40 行
|
||||
|
||||
### Step 7: Docker 部署自动注册(docker.js + dev-api.js)
|
||||
- `docker_create_container` 完成后自动注册
|
||||
- 健康检查 + 就绪等待
|
||||
- 预计:~60 行
|
||||
|
||||
### Step 8: 页面微调
|
||||
- dashboard 显示实例名
|
||||
- 远程实例时隐藏本机独占功能
|
||||
- 预计:~30 行
|
||||
|
||||
**总计新增代码:约 600 行**
|
||||
|
||||
---
|
||||
|
||||
## 6. 安全考虑
|
||||
|
||||
### 6.1 认证
|
||||
- 远程实例可能有不同的访问密码
|
||||
- 代理转发时需要携带目标实例的认证凭据
|
||||
- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定)
|
||||
|
||||
### 6.2 网络安全
|
||||
- Docker 容器默认只暴露在宿主机网络
|
||||
- 远程实例建议通过 SSH 隧道或 VPN 连接
|
||||
- 不建议在公网暴露 `/__api/` 端点而不加密码
|
||||
|
||||
### 6.3 权限隔离
|
||||
- AI 助手(assistant_*)始终操作本机文件系统,不代理到远程
|
||||
- Docker 管理(docker_*)始终操作本机 Docker,不代理
|
||||
|
||||
---
|
||||
|
||||
## 7. 边界与约束
|
||||
|
||||
### 7.1 不做的事情
|
||||
- **不做** 统一聚合视图(如"查看所有实例的模型列表")
|
||||
- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做
|
||||
- **不做** 实例间负载均衡
|
||||
- **不做** 复杂的权限角色系统
|
||||
|
||||
### 7.2 前提条件
|
||||
- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0
|
||||
- Docker 实例使用 full 镜像(含 Panel + Gateway)
|
||||
- 网络可达(ClawPanel 后端能访问远程实例的端口)
|
||||
|
||||
### 7.3 兼容性
|
||||
- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变
|
||||
- 实例切换器在只有本机时可以隐藏或最小化显示
|
||||
- 所有新功能向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试计划
|
||||
|
||||
| 场景 | 验证内容 |
|
||||
|------|---------|
|
||||
| 纯本机使用 | 现有功能不受影响,无回归 |
|
||||
| 部署 Docker 容器 | 自动注册为可管理实例 |
|
||||
| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 |
|
||||
| 切换实例后聊天 | WebSocket 连接到正确的 Gateway |
|
||||
| 远程实例离线 | 优雅报错,可切回本机 |
|
||||
| 删除 Docker 容器 | 实例列表自动移除 |
|
||||
| 多实例批量健康检查 | 侧边栏状态点实时更新 |
|
||||
@@ -1,288 +0,0 @@
|
||||
# ClawPanel i18n 国际化方案
|
||||
|
||||
> 本文档是 ClawPanel 多语言国际化的完整技术方案和实施指南。
|
||||
> 任何后续会话开始 i18n 工作时,请先阅读本文档。
|
||||
|
||||
## 一、现状评估
|
||||
|
||||
- **硬编码中文行数**:3508 行(分布在 25+ 个 JS 文件中)
|
||||
- **预估翻译字符串数**:约 1500+ 个
|
||||
- **技术栈**:纯 Vanilla JS(无 React/Vue),Tauri v2 桌面应用
|
||||
- **当前语言**:仅中文
|
||||
|
||||
### 文件中文行数分布(Top 15)
|
||||
|
||||
| 行数 | 文件 | 模块 |
|
||||
|------|------|------|
|
||||
| 838 | assistant.js | AI 助手(含内嵌知识库) |
|
||||
| 312 | docker.js | Docker 集群管理 |
|
||||
| 243 | models.js | 模型配置 |
|
||||
| 183 | chat.js | 实时聊天 |
|
||||
| 156 | chat-debug.js | 系统诊断 |
|
||||
| 148 | openclaw-kb.js | 知识库文本 |
|
||||
| 142 | setup.js | 初始安装引导 |
|
||||
| 136 | channels.js | 消息渠道 |
|
||||
| 136 | main.js | 主入口/路由/横幅 |
|
||||
| 120 | services.js | 服务管理 |
|
||||
| 105 | about.js | 关于页面 |
|
||||
| 93 | cron.js | 定时任务 |
|
||||
| 88 | dashboard.js | 仪表盘 |
|
||||
| 72 | extensions.js | 扩展工具 |
|
||||
| 68 | gateway.js | 网关配置 |
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 核心模块:`src/lib/i18n.js`
|
||||
|
||||
```js
|
||||
// 使用方式
|
||||
import { t, setLocale, getLocale } from '../lib/i18n.js'
|
||||
|
||||
// 简单翻译
|
||||
t('common.save') // → "保存" / "Save"
|
||||
t('common.cancel') // → "取消" / "Cancel"
|
||||
|
||||
// 带参数插值
|
||||
t('chat.messageCount', { count: 5 }) // → "5 条消息" / "5 messages"
|
||||
|
||||
// 嵌套 key
|
||||
t('dashboard.gateway.running') // → "运行中" / "Running"
|
||||
|
||||
// 切换语言
|
||||
setLocale('en') // 存 localStorage,触发页面重渲染
|
||||
```
|
||||
|
||||
### 语言检测优先级
|
||||
|
||||
1. `localStorage` 中存储的用户选择 (`clawpanel-locale`)
|
||||
2. 浏览器 `navigator.language`(`zh-CN` → `zh-CN`,`en-US` → `en`)
|
||||
3. 默认值:`zh-CN`
|
||||
|
||||
### 缺失翻译 fallback
|
||||
|
||||
1. 查找当前语言包
|
||||
2. 查找 `zh-CN` 兜底(中文作为最完整的语言)
|
||||
3. 返回 key 本身(如 `common.save`)
|
||||
4. 开发模式下 console.warn 提示缺失翻译
|
||||
|
||||
## 三、语言包结构
|
||||
|
||||
```
|
||||
src/locales/
|
||||
zh-CN.json — 中文简体(默认,最完整)
|
||||
en.json — English
|
||||
zh-TW.json — 中文繁体(未来)
|
||||
ja.json — 日本語(未来)
|
||||
ko.json — 한국어(未来)
|
||||
```
|
||||
|
||||
### JSON 格式规范
|
||||
|
||||
按模块/页面分组,使用扁平化嵌套结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"confirm": "确定",
|
||||
"close": "关闭",
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"warning": "警告",
|
||||
"retry": "重试",
|
||||
"refresh": "刷新",
|
||||
"edit": "编辑",
|
||||
"create": "创建",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"search": "搜索",
|
||||
"copy": "复制",
|
||||
"download": "下载",
|
||||
"upload": "上传",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"start": "启动",
|
||||
"stop": "停止",
|
||||
"restart": "重启",
|
||||
"status": "状态",
|
||||
"running": "运行中",
|
||||
"stopped": "已停止",
|
||||
"unknown": "未知",
|
||||
"noData": "暂无数据",
|
||||
"operationFailed": "操作失败: {error}",
|
||||
"confirmDelete": "确定删除 {name}?",
|
||||
"savedSuccessfully": "已保存"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "仪表盘",
|
||||
"assistant": "晴辰助手",
|
||||
"chat": "实时聊天",
|
||||
"services": "服务管理",
|
||||
"logs": "日志查看",
|
||||
"models": "模型配置",
|
||||
"agents": "Agent 管理",
|
||||
"memory": "记忆文件",
|
||||
"channels": "消息渠道",
|
||||
"gateway": "网关配置",
|
||||
"skills": "Skills 工具",
|
||||
"docker": "Docker 集群",
|
||||
"cron": "定时任务",
|
||||
"extensions": "扩展工具",
|
||||
"about": "关于",
|
||||
"setup": "初始设置",
|
||||
"chatDebug": "系统诊断"
|
||||
},
|
||||
"dashboard": { ... },
|
||||
"chat": { ... },
|
||||
"models": { ... },
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 四、迁移步骤(每个页面)
|
||||
|
||||
### Step 1: 提取中文字符串
|
||||
|
||||
使用正则或手动扫描,将所有中文文本提取到对应的语言包 key 下。
|
||||
|
||||
**需要翻译的内容**:
|
||||
- UI 文本(按钮文字、标题、描述、提示)
|
||||
- toast 消息
|
||||
- 错误消息
|
||||
- placeholder 文本
|
||||
- confirm 对话框文本
|
||||
- tooltip 文本
|
||||
|
||||
**不需要翻译的内容**:
|
||||
- 代码注释(保持中文)
|
||||
- console.log 调试信息
|
||||
- 技术标识符(如 `Gateway`、`Agent`、`OpenClaw`)
|
||||
- API 错误消息(后端返回的)
|
||||
- 知识库内容 `openclaw-kb.js`(这个特殊处理,按语言版本分文件)
|
||||
|
||||
### Step 2: 替换代码中的硬编码
|
||||
|
||||
```js
|
||||
// Before
|
||||
toast('保存成功', 'success')
|
||||
|
||||
// After
|
||||
toast(t('common.savedSuccessfully'), 'success')
|
||||
```
|
||||
|
||||
```js
|
||||
// Before (HTML 模板)
|
||||
`<button class="btn">${icon('save', 14)} 保存</button>`
|
||||
|
||||
// After
|
||||
`<button class="btn">${icon('save', 14)} ${t('common.save')}</button>`
|
||||
```
|
||||
|
||||
### Step 3: 编写英文翻译
|
||||
|
||||
逐 key 翻译到 `en.json`。
|
||||
|
||||
### Step 4: 测试
|
||||
|
||||
切换语言,检查每个页面的显示是否正常。
|
||||
|
||||
## 五、迁移顺序
|
||||
|
||||
### 第一批(基础 + 框架层,约 80 个字符串)
|
||||
1. `src/lib/i18n.js` — 创建核心模块
|
||||
2. `src/locales/zh-CN.json` — 初始化中文包
|
||||
3. `src/locales/en.json` — 初始化英文包
|
||||
4. `src/components/sidebar.js` — 导航菜单(~20 个)
|
||||
5. `src/components/modal.js` — 公共弹窗(~10 个)
|
||||
6. `src/components/toast.js` — 提示组件
|
||||
7. `src/pages/about.js` — 关于页面 + 语言切换 UI(~30 个)
|
||||
|
||||
### 第二批(核心页面,约 250 个字符串)
|
||||
8. `src/pages/dashboard.js` — 仪表盘(~50 个)
|
||||
9. `src/pages/setup.js` — 初始设置(~80 个)
|
||||
10. `src/pages/chat.js` — 实时聊天(~100 个)
|
||||
11. `src/main.js` — 主入口/横幅(~20 个)
|
||||
|
||||
### 第三批(配置页面,约 350 个字符串)
|
||||
12. `src/pages/models.js` — 模型配置(~120 个)
|
||||
13. `src/pages/channels.js` — 消息渠道(~80 个)
|
||||
14. `src/pages/services.js` — 服务管理(~70 个)
|
||||
15. `src/pages/gateway.js` — 网关配置(~40 个)
|
||||
16. `src/pages/agents.js` — Agent 管理(~40 个)
|
||||
|
||||
### 第四批(功能页面,约 250 个字符串)
|
||||
17. `src/pages/cron.js` — 定时任务(~50 个)
|
||||
18. `src/pages/memory.js` — 记忆管理(~30 个)
|
||||
19. `src/pages/extensions.js` — 扩展工具(~40 个)
|
||||
20. `src/pages/logs.js` — 日志查看(~20 个)
|
||||
21. `src/pages/skills.js` — Skills 工具(~60 个)
|
||||
22. `src/pages/chat-debug.js` — 系统诊断(~50 个)
|
||||
|
||||
### 第五批(大型页面 + 特殊处理,约 600 个字符串)
|
||||
23. `src/pages/docker.js` — Docker 管理(~150 个)
|
||||
24. `src/pages/assistant.js` — AI 助手(~400 个,含系统提示词)
|
||||
25. `src/lib/openclaw-kb.js` — 知识库(按语言分文件)
|
||||
26. `src/lib/error-diagnosis.js` — 错误诊断(~30 个)
|
||||
27. `src/components/engagement.js` — 推荐弹窗(~15 个)
|
||||
|
||||
### 第六批(官网 + 文档)
|
||||
28. `docs/index.html` — 官网英文版
|
||||
29. `README.md` → `README_en.md`
|
||||
30. `CONTRIBUTING.md` → `CONTRIBUTING_en.md`
|
||||
|
||||
## 六、语言切换 UI 设计
|
||||
|
||||
### 位置
|
||||
1. **关于页面底部** — 语言选择下拉框
|
||||
2. **侧边栏底部** — 语言图标 + 当前语言缩写(如 `中` / `EN`)
|
||||
|
||||
### 交互
|
||||
- 选择语言 → 存入 localStorage → 页面自动刷新
|
||||
- 首次访问自动检测浏览器语言
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
### 技术品牌词不翻译
|
||||
以下词保持原样,不翻译:
|
||||
- `OpenClaw`
|
||||
- `ClawPanel`
|
||||
- `Gateway`
|
||||
- `Agent`(Agent 管理不翻译为"代理")
|
||||
- `MCP`
|
||||
- `Skills`
|
||||
- `Docker`
|
||||
- `Tauri`
|
||||
|
||||
### 参数插值语法
|
||||
使用 `{param}` 语法:
|
||||
```json
|
||||
{
|
||||
"chat.sessions": "{count} sessions",
|
||||
"models.providers": "Based on {count} providers"
|
||||
}
|
||||
```
|
||||
|
||||
### 复数形式
|
||||
英文需要处理复数,但 MVP 阶段可以用简单方式:
|
||||
```json
|
||||
{
|
||||
"chat.messageCount": "{count} message(s)"
|
||||
}
|
||||
```
|
||||
|
||||
### Rust 后端
|
||||
后端错误消息暂不国际化(工作量大且用户较少直接看到),保持中文。
|
||||
|
||||
## 八、验证清单
|
||||
|
||||
每批迁移完成后检查:
|
||||
- [ ] 中文模式下所有功能正常
|
||||
- [ ] 英文模式下所有功能正常
|
||||
- [ ] 语言切换后页面正确刷新
|
||||
- [ ] 没有遗漏的硬编码中文
|
||||
- [ ] 参数插值正确显示
|
||||
- [ ] 长英文文本不溢出布局
|
||||
- [ ] toast/modal/confirm 文本正确
|
||||
@@ -34,7 +34,7 @@
|
||||
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
|
||||
"url": "https://claw.qt.cool/",
|
||||
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
|
||||
"softwareVersion": "0.9.7",
|
||||
"softwareVersion": "0.9.8",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "晴辰云 QingchenCloud",
|
||||
@@ -1133,7 +1133,7 @@
|
||||
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
|
||||
<div class="container-sm" style="position:relative;z-index:10">
|
||||
<div class="section-header">
|
||||
<div class="reveal download-version"><span class="pulse"></span> v0.9.7 最新版</div>
|
||||
<div class="reveal download-version"><span class="pulse"></span> v0.9.8 最新版</div>
|
||||
<h2 class="reveal section-title"><span class="gradient-text">下载安装</span></h2>
|
||||
<p class="reveal section-desc">选择你的操作系统,一键下载安装</p>
|
||||
</div>
|
||||
@@ -1143,11 +1143,11 @@
|
||||
<h3>macOS</h3>
|
||||
<p class="dl-desc">支持 Apple Silicon 和 Intel 芯片</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_aarch64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_aarch64.dmg" target="_blank" rel="noopener">
|
||||
Apple Silicon (M1/M2/M3/M4)
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_x64.dmg" target="_blank" rel="noopener">
|
||||
Intel 芯片
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
@@ -1165,11 +1165,11 @@
|
||||
<h3>Windows</h3>
|
||||
<p class="dl-desc">支持 Windows 10 及以上版本</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64-setup.exe" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_x64-setup.exe" target="_blank" rel="noopener">
|
||||
安装程序
|
||||
<span class="dl-format">.exe</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
MSI 安装包
|
||||
<span class="dl-format">.msi</span>
|
||||
</a>
|
||||
@@ -1180,11 +1180,11 @@
|
||||
<h3>Linux</h3>
|
||||
<p class="dl-desc">支持主流 Linux 发行版</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_amd64.AppImage" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_amd64.AppImage" target="_blank" rel="noopener">
|
||||
通用版
|
||||
<span class="dl-format">.AppImage</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.7_amd64.deb" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.8_amd64.deb" target="_blank" rel="noopener">
|
||||
Debian / Ubuntu
|
||||
<span class="dl-format">.deb</span>
|
||||
</a>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "0.9.7",
|
||||
"minAppVersion": "0.9.7",
|
||||
"hash": "sha256:e853ece18ca4f885b8e770601a4b38f553f1c36f2f4469e071c36395b33cdeca",
|
||||
"url": "https://github.com/qingchencloud/clawpanel/releases/download/v0.9.7/web-0.9.7.zip",
|
||||
"size": 1999195,
|
||||
"version": "0.9.8",
|
||||
"minAppVersion": "0.9.0",
|
||||
"hash": "",
|
||||
"url": "https://github.com/qingchencloud/clawpanel/releases/download/v0.9.8/web-0.9.8.zip",
|
||||
"size": 0,
|
||||
"changelog": "",
|
||||
"releasedAt": "2026-03-20T20:20:47Z"
|
||||
"releasedAt": "2026-03-23T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="sp-bar"><div class="sp-bar-inner"></div></div>
|
||||
<div class="sp-site"><a href="https://qt.cool" target="_blank">qt.cool</a></div>
|
||||
</div>
|
||||
<script>setTimeout(function(){var s=document.getElementById('splash');if(s){var app=document.getElementById('content');if(app&&app.children.length>0){s.classList.add('hide');setTimeout(function(){s.remove()},500)}else{s.innerHTML='<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif"><div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div><div style="font-size:16px;font-weight:600;color:#18181b;margin-bottom:8px">\u9875\u9762\u52A0\u8F7D\u5931\u8D25</div><div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.6">\u53EF\u80FD\u539F\u56E0\uFF1AWebView2 \u7F3A\u5931\u6216\u7248\u672C\u8FC7\u4F4E<br>\u8BF7\u5B89\u88C5\u6700\u65B0\u7248 <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a></div><button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>'}}},8000)</script>
|
||||
<script>window._splashTimer=setTimeout(function(){var s=document.getElementById('splash');if(s){var app=document.getElementById('content');if(app&&app.children.length>0){s.classList.add('hide');setTimeout(function(){s.remove()},500)}else{s.innerHTML='<div style="text-align:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif"><div style="font-size:40px;margin-bottom:12px">\u26A0\uFE0F</div><div style="font-size:16px;font-weight:600;color:#18181b;margin-bottom:8px">\u9875\u9762\u52A0\u8F7D\u5931\u8D25</div><div style="font-size:12px;color:#71717a;margin-bottom:16px;line-height:1.6">\u53EF\u80FD\u539F\u56E0\uFF1A\u5E94\u7528\u521D\u59CB\u5316\u8D85\u65F6\uFF0C\u8BF7\u68C0\u67E5\u63A7\u5236\u53F0\u662F\u5426\u6709\u62A5\u9519<br>\u5982\u786E\u8BA4 WebView2 \u672A\u5B89\u88C5\uFF0C\u8BF7\u4E0B\u8F7D <a href="https://go.microsoft.com/fwlink/p/?LinkId=2124703" style="color:#6366f1">WebView2 Runtime</a></div><button onclick="location.reload()" style="padding:6px 16px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-size:12px;cursor:pointer">\u5237\u65B0\u91CD\u8BD5</button></div>'}}},15000)</script>
|
||||
|
||||
<div id="app">
|
||||
<aside id="sidebar"></aside>
|
||||
|
||||
@@ -47,6 +47,46 @@
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.4": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.5": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.6": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.7": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.8": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.9.7",
|
||||
"version": "0.9.8",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
|
||||
"type": "module",
|
||||
|
||||
@@ -1772,7 +1772,7 @@ const handlers = {
|
||||
install_qqbot_plugin() {
|
||||
const bin = findOpenclawBin() || 'openclaw'
|
||||
try {
|
||||
execSync(`${bin} plugins install @sliverp/qqbot@latest`, { timeout: 60000, cwd: homedir() })
|
||||
execSync(`${bin} plugins install @tencent-connect/openclaw-qqbot@latest`, { timeout: 600000, cwd: homedir() })
|
||||
return '安装成功'
|
||||
} catch (e) {
|
||||
throw new Error('QQBot 插件安装失败: ' + (e.message || e))
|
||||
@@ -3122,6 +3122,75 @@ const handlers = {
|
||||
return result
|
||||
},
|
||||
|
||||
// Agent 渠道绑定管理
|
||||
list_all_bindings() {
|
||||
const cfg = readConfig()
|
||||
const bindings = cfg.bindings || []
|
||||
return { bindings }
|
||||
},
|
||||
|
||||
save_agent_binding({ agentId, channel, accountId, bindingConfig }) {
|
||||
const cfg = readConfig()
|
||||
if (!cfg.bindings) cfg.bindings = []
|
||||
const bindings = cfg.bindings
|
||||
|
||||
// 构建新绑定
|
||||
const newBinding = {
|
||||
type: 'route',
|
||||
agentId,
|
||||
match: {
|
||||
channel,
|
||||
...(accountId ? { accountId } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
// 合并 peer 配置到 match
|
||||
if (bindingConfig && typeof bindingConfig === 'object') {
|
||||
if (bindingConfig.peer) {
|
||||
newBinding.match.peer = bindingConfig.peer
|
||||
}
|
||||
}
|
||||
|
||||
// 查找并更新现有绑定(相同 agentId + channel + accountId)
|
||||
const accountKey = accountId || ''
|
||||
let found = false
|
||||
for (let i = 0; i < bindings.length; i++) {
|
||||
const b = bindings[i]
|
||||
if (b.agentId === agentId && b.match?.channel === channel) {
|
||||
const existingAccount = b.match?.accountId || ''
|
||||
if (existingAccount === accountKey) {
|
||||
bindings[i] = newBinding
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
bindings.push(newBinding)
|
||||
}
|
||||
|
||||
saveConfig(cfg)
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
delete_agent_binding({ agentId, channel, accountId }) {
|
||||
const cfg = readConfig()
|
||||
if (!cfg.bindings) cfg.bindings = []
|
||||
const bindings = cfg.bindings
|
||||
const accountKey = accountId || ''
|
||||
|
||||
const before = bindings.length
|
||||
cfg.bindings = bindings.filter(b => {
|
||||
if (b.agentId !== agentId) return true
|
||||
if (b.match?.channel !== channel) return true
|
||||
const existingAccount = b.match?.accountId || ''
|
||||
return existingAccount !== accountKey
|
||||
})
|
||||
|
||||
saveConfig(cfg)
|
||||
return { ok: true, removed: before - cfg.bindings.length }
|
||||
},
|
||||
|
||||
// 记忆文件
|
||||
list_memory_files({ category, agent_id }) {
|
||||
const suffix = agent_id && agent_id !== 'main' ? `/agents/${agent_id}` : ''
|
||||
|
||||
536
scripts/docker-deploy.sh
Normal file
536
scripts/docker-deploy.sh
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# ClawPanel Docker 部署脚本
|
||||
# =============================================================================
|
||||
# 功能:
|
||||
# 1. 检查 Docker 环境
|
||||
# 2. 构建 Docker 镜像
|
||||
# 3. 启动/停止/重启容器
|
||||
# 4. 查看日志
|
||||
# 5. 常见问题排查
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 颜色定义
|
||||
# -----------------------------------------------------------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 配置
|
||||
# -----------------------------------------------------------------------------
|
||||
CONTAINER_NAME="clawpanel"
|
||||
IMAGE_NAME="clawpanel"
|
||||
IMAGE_TAG="latest"
|
||||
DEFAULT_PORT=1420
|
||||
CONFIG_DIR="$HOME/.openclaw"
|
||||
DATA_DIR="$(pwd)/data"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 工具函数
|
||||
# -----------------------------------------------------------------------------
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${CYAN}[STEP]${NC} $1"
|
||||
}
|
||||
|
||||
separator() {
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 检查 Docker 环境
|
||||
# -----------------------------------------------------------------------------
|
||||
check_docker() {
|
||||
log_step "检查 Docker 环境..."
|
||||
|
||||
# 检查 Docker 是否安装
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker 未安装或不在 PATH 中"
|
||||
echo ""
|
||||
echo "请先安装 Docker:"
|
||||
echo " Ubuntu/Debian: curl -fsSL https://get.docker.com | sh"
|
||||
echo " CentOS/RHEL: yum install -y docker-ce"
|
||||
echo " Arch Linux: pacman -S docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker 服务是否运行
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker 服务未运行"
|
||||
echo ""
|
||||
echo "请启动 Docker 服务:"
|
||||
echo " sudo systemctl start docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker Compose
|
||||
if docker compose version &> /dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
log_info "Docker Compose v2 可用"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
log_info "Docker Compose v1 可用"
|
||||
else
|
||||
log_warn "Docker Compose 未安装,部分功能可能不可用"
|
||||
COMPOSE_CMD=""
|
||||
fi
|
||||
|
||||
log_info "Docker 环境检查通过"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 检查前置要求
|
||||
# -----------------------------------------------------------------------------
|
||||
check_requirements() {
|
||||
log_step "检查前置要求..."
|
||||
|
||||
# 检查构建上下文
|
||||
if [ ! -f "Dockerfile" ]; then
|
||||
log_error "Dockerfile 不存在,请确保在项目根目录运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "package.json" ]; then
|
||||
log_error "package.json 不存在,请确保在项目根目录运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建必要目录
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
log_info "前置要求检查通过"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 拉取最新代码(可选)
|
||||
# -----------------------------------------------------------------------------
|
||||
pull_latest() {
|
||||
log_step "检查更新..."
|
||||
|
||||
if [ -d ".git" ]; then
|
||||
git fetch origin main
|
||||
LOCAL=$(git rev-parse @)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
log_warn "本地版本落后于远程,是否更新?"
|
||||
read -p "输入 y 更新,其他跳过: " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "更新代码..."
|
||||
git pull origin main
|
||||
fi
|
||||
else
|
||||
log_info "已是最新版本"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 构建镜像
|
||||
# -----------------------------------------------------------------------------
|
||||
build_image() {
|
||||
log_step "构建 Docker 镜像..."
|
||||
|
||||
# 启用 BuildKit
|
||||
export DOCKER_BUILDKIT=1
|
||||
|
||||
# 构建镜像
|
||||
log_info "构建镜像: ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
if docker build \
|
||||
--tag "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
--tag "${IMAGE_NAME}:latest" \
|
||||
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
||||
--progress=plain \
|
||||
.; then
|
||||
log_info "镜像构建成功"
|
||||
else
|
||||
log_error "镜像构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示镜像大小
|
||||
IMAGE_SIZE=$(docker images "${IMAGE_NAME}:${IMAGE_TAG}" --format "{{.Size}}")
|
||||
log_info "镜像大小: $IMAGE_SIZE"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 启动容器
|
||||
# -----------------------------------------------------------------------------
|
||||
start_container() {
|
||||
log_step "启动容器..."
|
||||
|
||||
# 检查容器是否已存在
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
log_warn "容器已在运行中"
|
||||
return 0
|
||||
else
|
||||
log_info "容器已存在,重新启动..."
|
||||
docker rm -f "$CONTAINER_NAME" > /dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查配置目录
|
||||
if [ ! -d "$CONFIG_DIR" ]; then
|
||||
log_warn "OpenClaw 配置目录不存在: $CONFIG_DIR"
|
||||
log_info "将创建目录..."
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
fi
|
||||
|
||||
# 启动容器(使用 host 网络模式)
|
||||
log_info "启动容器 (host 网络模式)..."
|
||||
|
||||
docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--hostname "$CONTAINER_NAME" \
|
||||
--network host \
|
||||
--restart unless-stopped \
|
||||
--volume "$CONFIG_DIR:/root/.openclaw" \
|
||||
--volume "$DATA_DIR:/app/data" \
|
||||
--env "NODE_ENV=production" \
|
||||
--env "OPENCLAW_URL=http://127.0.0.1:18789" \
|
||||
--env "TZ=Asia/Shanghai" \
|
||||
--health-cmd "curl -f http://localhost:1420/ || exit 1" \
|
||||
--health-interval "30s" \
|
||||
--health-timeout "5s" \
|
||||
--health-retries "3" \
|
||||
--log-driver "json-file" \
|
||||
--log-opt "max-size=10m" \
|
||||
--log-opt "max-file=3" \
|
||||
"${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
log_info "容器启动成功"
|
||||
else
|
||||
log_error "容器启动失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 停止容器
|
||||
# -----------------------------------------------------------------------------
|
||||
stop_container() {
|
||||
log_step "停止容器..."
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker stop "$CONTAINER_NAME"
|
||||
log_info "容器已停止"
|
||||
else
|
||||
log_warn "容器未运行"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 重启容器
|
||||
# -----------------------------------------------------------------------------
|
||||
restart_container() {
|
||||
log_step "重启容器..."
|
||||
stop_container
|
||||
sleep 2
|
||||
start_container
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 删除容器
|
||||
# -----------------------------------------------------------------------------
|
||||
remove_container() {
|
||||
log_step "删除容器..."
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker rm -f "$CONTAINER_NAME" > /dev/null 2>&1
|
||||
log_info "容器已删除"
|
||||
else
|
||||
log_warn "容器不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 查看状态
|
||||
# -----------------------------------------------------------------------------
|
||||
show_status() {
|
||||
log_step "容器状态:"
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo ""
|
||||
docker ps -a --filter "name=${CONTAINER_NAME}" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
# 显示资源使用
|
||||
echo ""
|
||||
log_info "资源使用:"
|
||||
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" "$CONTAINER_NAME" 2>/dev/null || true
|
||||
else
|
||||
log_warn "容器不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 查看日志
|
||||
# -----------------------------------------------------------------------------
|
||||
show_logs() {
|
||||
log_step "容器日志 (Ctrl+C 退出):"
|
||||
echo ""
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker logs -f "$CONTAINER_NAME"
|
||||
else
|
||||
log_error "容器未运行,无法查看日志"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 进入容器
|
||||
# -----------------------------------------------------------------------------
|
||||
enter_container() {
|
||||
log_step "进入容器 shell..."
|
||||
echo ""
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
docker exec -it "$CONTAINER_NAME" /bin/sh
|
||||
else
|
||||
log_error "容器未运行,无法进入"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 常见问题排查
|
||||
# -----------------------------------------------------------------------------
|
||||
troubleshoot() {
|
||||
echo ""
|
||||
separator
|
||||
log_info "常见问题排查"
|
||||
separator
|
||||
echo ""
|
||||
|
||||
log_info "1. 检查容器是否运行:"
|
||||
echo " docker ps | grep $CONTAINER_NAME"
|
||||
echo ""
|
||||
|
||||
log_info "2. 查看容器日志:"
|
||||
echo " docker logs $CONTAINER_NAME"
|
||||
echo ""
|
||||
|
||||
log_info "3. 检查端口占用:"
|
||||
echo " netstat -tlnp | grep 1420"
|
||||
echo " ss -tlnp | grep 1420"
|
||||
echo ""
|
||||
|
||||
log_info "4. 检查 OpenClaw 配置:"
|
||||
echo " cat ~/.openclaw/openclaw.json"
|
||||
echo ""
|
||||
|
||||
log_info "5. 测试 Gateway 连接:"
|
||||
echo " curl http://localhost:18789/health"
|
||||
echo ""
|
||||
|
||||
log_info "6. 重建容器:"
|
||||
echo " ./docker-deploy.sh rebuild"
|
||||
echo ""
|
||||
|
||||
log_info "7. 完全重置:"
|
||||
echo " docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME"
|
||||
echo " docker rmi ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
echo " ./docker-deploy.sh start"
|
||||
echo ""
|
||||
|
||||
log_info "8. 查看 OpenClaw 日志:"
|
||||
echo " docker exec $CONTAINER_NAME cat /home/appuser/.openclaw/logs/gateway.log"
|
||||
echo ""
|
||||
|
||||
separator
|
||||
echo ""
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取本机 IP
|
||||
# -----------------------------------------------------------------------------
|
||||
get_local_ip() {
|
||||
ip route get 1 2>/dev/null | awk '{print $7; exit}' || \
|
||||
hostname -I 2>/dev/null | awk '{print $1}' || \
|
||||
echo "localhost"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 显示访问信息
|
||||
# -----------------------------------------------------------------------------
|
||||
show_access_info() {
|
||||
local ip=$(get_local_ip)
|
||||
|
||||
echo ""
|
||||
separator
|
||||
log_info "部署完成!"
|
||||
separator
|
||||
echo ""
|
||||
echo -e " ${CYAN}🌐 访问地址:${NC}"
|
||||
echo " http://${ip}:1420"
|
||||
echo ""
|
||||
echo -e " ${CYAN}📁 配置目录:${NC}"
|
||||
echo " $CONFIG_DIR"
|
||||
echo ""
|
||||
echo -e " ${CYAN}📋 容器名称:${NC}"
|
||||
echo " $CONTAINER_NAME"
|
||||
echo ""
|
||||
echo " 常用命令:"
|
||||
echo " ./docker-deploy.sh logs # 查看日志"
|
||||
echo " ./docker-deploy.sh status # 查看状态"
|
||||
echo " ./docker-deploy.sh stop # 停止"
|
||||
echo " ./docker-deploy.sh start # 启动"
|
||||
echo " ./docker-deploy.sh restart # 重启"
|
||||
echo " ./docker-deploy.sh rebuild # 重建"
|
||||
echo " ./docker-deploy.sh shell # 进入容器"
|
||||
echo " ./docker-deploy.sh help # 帮助"
|
||||
echo ""
|
||||
separator
|
||||
echo ""
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 使用 Docker Compose 方式
|
||||
# -----------------------------------------------------------------------------
|
||||
compose_up() {
|
||||
if [ -z "$COMPOSE_CMD" ]; then
|
||||
log_error "Docker Compose 不可用,请使用单机模式"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "使用 Docker Compose 启动..."
|
||||
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
log_error "docker-compose.yml 不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$COMPOSE_CMD up -d
|
||||
log_info "服务已启动"
|
||||
}
|
||||
|
||||
compose_down() {
|
||||
if [ -z "$COMPOSE_CMD" ]; then
|
||||
log_error "Docker Compose 不可用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "停止 Docker Compose 服务..."
|
||||
$COMPOSE_CMD down
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 显示帮助
|
||||
# -----------------------------------------------------------------------------
|
||||
show_help() {
|
||||
echo ""
|
||||
echo "ClawPanel Docker 部署脚本"
|
||||
echo ""
|
||||
echo "用法: $0 [命令]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start 启动容器"
|
||||
echo " stop 停止容器"
|
||||
echo " restart 重启容器"
|
||||
echo " rebuild 重建容器(删除并重新创建)"
|
||||
echo " remove 删除容器(保留镜像)"
|
||||
echo " status 查看容器状态"
|
||||
echo " logs 查看容器日志"
|
||||
echo " shell 进入容器 shell"
|
||||
echo " troubleshoot 常见问题排查"
|
||||
echo " compose 使用 Docker Compose 方式启动"
|
||||
echo " help 显示帮助"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 start # 启动容器"
|
||||
echo " $0 logs -f # 实时查看日志"
|
||||
echo " $0 rebuild # 重建容器"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 主流程
|
||||
# -----------------------------------------------------------------------------
|
||||
main() {
|
||||
case "${1:-help}" in
|
||||
check)
|
||||
check_docker
|
||||
;;
|
||||
build)
|
||||
check_docker
|
||||
check_requirements
|
||||
pull_latest
|
||||
build_image
|
||||
;;
|
||||
start)
|
||||
check_docker
|
||||
check_requirements
|
||||
start_container
|
||||
show_access_info
|
||||
;;
|
||||
stop)
|
||||
stop_container
|
||||
;;
|
||||
restart)
|
||||
restart_container
|
||||
;;
|
||||
rebuild)
|
||||
check_docker
|
||||
remove_container
|
||||
build_image
|
||||
start_container
|
||||
show_access_info
|
||||
;;
|
||||
remove)
|
||||
remove_container
|
||||
;;
|
||||
status)
|
||||
check_docker
|
||||
show_status
|
||||
;;
|
||||
logs)
|
||||
show_logs
|
||||
;;
|
||||
shell)
|
||||
enter_container
|
||||
;;
|
||||
troubleshoot)
|
||||
troubleshoot
|
||||
;;
|
||||
compose)
|
||||
check_docker
|
||||
compose_up
|
||||
show_access_info
|
||||
;;
|
||||
compose-down)
|
||||
compose_down
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
log_error "未知命令: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -129,18 +129,69 @@ install_git() {
|
||||
echo "✅ Git 安装完成"
|
||||
}
|
||||
|
||||
# 查找 openclaw 可执行文件(兼容各种安装方式)
|
||||
find_openclaw() {
|
||||
# 1. 直接在 PATH 中查找
|
||||
if command -v openclaw &> /dev/null; then
|
||||
echo "$(command -v openclaw)"
|
||||
return 0
|
||||
fi
|
||||
# 2. 常见 npm 全局安装路径
|
||||
local candidates=(
|
||||
"/usr/local/bin/openclaw"
|
||||
"/usr/bin/openclaw"
|
||||
"$HOME/.npm-global/bin/openclaw"
|
||||
"$HOME/.local/bin/openclaw"
|
||||
)
|
||||
# 3. 从 npm prefix 获取(不使用 sudo,避免触发密码提示)
|
||||
local npm_prefix=$(npm config get prefix 2>/dev/null)
|
||||
if [ -n "$npm_prefix" ]; then
|
||||
candidates+=("$npm_prefix/bin/openclaw")
|
||||
fi
|
||||
for p in "${candidates[@]}"; do
|
||||
if [ -x "$p" ]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 检测 OpenClaw 版本来源(官方 vs 汉化版)
|
||||
detect_openclaw_source() {
|
||||
local oc_bin="$1"
|
||||
local ver=$("$oc_bin" --version 2>/dev/null || echo "")
|
||||
if echo "$ver" | grep -qi "zh\|汉化\|chinese"; then
|
||||
echo "chinese"
|
||||
else
|
||||
echo "official"
|
||||
fi
|
||||
}
|
||||
|
||||
# 安装 OpenClaw
|
||||
install_openclaw() {
|
||||
if command -v openclaw &> /dev/null; then
|
||||
echo "✅ OpenClaw 已安装: $(openclaw --version 2>/dev/null || echo '未知版本')"
|
||||
local oc_path=$(find_openclaw)
|
||||
if [ -n "$oc_path" ]; then
|
||||
local oc_ver=$("$oc_path" --version 2>/dev/null || echo "未知版本")
|
||||
local oc_src=$(detect_openclaw_source "$oc_path")
|
||||
if [ "$oc_src" = "chinese" ]; then
|
||||
echo "✅ OpenClaw 汉化版已安装: $oc_ver (${oc_path})"
|
||||
else
|
||||
echo "✅ OpenClaw 已安装: $oc_ver (${oc_path})"
|
||||
fi
|
||||
# 确保 openclaw 在 PATH 中(防止后续步骤找不到)
|
||||
if ! command -v openclaw &> /dev/null; then
|
||||
export PATH="$(dirname "$oc_path"):$PATH"
|
||||
echo "ℹ️ 已将 $(dirname "$oc_path") 加入 PATH"
|
||||
fi
|
||||
else
|
||||
echo "📦 安装 OpenClaw 汉化版..."
|
||||
if [ "$IS_ROOT" = true ]; then
|
||||
npm install -g @qingchencloud/openclaw-zh --registry "$NPM_REGISTRY" || \
|
||||
npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org
|
||||
else
|
||||
sudo npm install -g @qingchencloud/openclaw-zh --registry "$NPM_REGISTRY" || \
|
||||
sudo npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org
|
||||
sudo -E npm install -g @qingchencloud/openclaw-zh --registry "$NPM_REGISTRY" || \
|
||||
sudo -E npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmjs.org
|
||||
fi
|
||||
echo "✅ OpenClaw 安装完成"
|
||||
fi
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -340,7 +340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.9.7"
|
||||
version = "0.9.8"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.9.7"
|
||||
version = "0.9.8"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -1,9 +1,95 @@
|
||||
/// Agent 管理命令 — 列表/改名直接读写 openclaw.json;创建/删除走 CLI(需要创建 workspace 等文件)
|
||||
use crate::utils::openclaw_command_async;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
/// Workspace 状态信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceStatus {
|
||||
/// 路径是否存在
|
||||
pub exists: bool,
|
||||
/// 是否为软链接
|
||||
pub is_symlink: bool,
|
||||
/// 软链接指向的目标路径(如果是软链接)
|
||||
pub symlink_target: Option<String>,
|
||||
/// 软链接目标是否有效(仅当 is_symlink=true 时有意义)
|
||||
pub symlink_valid: bool,
|
||||
/// 是否有读取权限
|
||||
pub readable: bool,
|
||||
}
|
||||
|
||||
/// Workspace 状态检测结果(包含状态和警告信息)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceCheckResult {
|
||||
pub status: WorkspaceStatus,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 检测 workspace 路径的状态
|
||||
/// 使用 symlink_metadata 而非 metadata,避免跟随软链接
|
||||
fn check_workspace_status(path: &std::path::Path) -> WorkspaceCheckResult {
|
||||
let mut status = WorkspaceStatus {
|
||||
exists: false,
|
||||
is_symlink: false,
|
||||
symlink_target: None,
|
||||
symlink_valid: false,
|
||||
readable: true,
|
||||
};
|
||||
let mut warning = None;
|
||||
|
||||
// 使用 symlink_metadata 不会跟随软链接,能正确检测软链接本身的状态
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) => {
|
||||
status.exists = true;
|
||||
status.is_symlink = meta.file_type().is_symlink();
|
||||
|
||||
if status.is_symlink {
|
||||
// 软链接:获取目标路径
|
||||
match std::fs::read_link(path) {
|
||||
Ok(target) => {
|
||||
status.symlink_target = Some(target.to_string_lossy().to_string());
|
||||
// 检查软链接目标是否存在
|
||||
match std::fs::metadata(path) {
|
||||
Ok(_) => status.symlink_valid = true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
status.symlink_valid = false;
|
||||
warning = Some("软链接目标不存在".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
status.symlink_valid = false;
|
||||
warning = Some(format!("无法访问软链接目标: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warning = Some(format!("无法读取软链接目标: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 普通目录:验证读取权限
|
||||
match std::fs::read_dir(path) {
|
||||
Ok(_) => status.readable = true,
|
||||
Err(e) => {
|
||||
status.readable = false;
|
||||
warning = Some(format!("权限不足: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
warning = Some("工作目录不存在".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
status.readable = false;
|
||||
warning = Some(format!("无法访问路径: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceCheckResult { status, warning }
|
||||
}
|
||||
|
||||
/// 获取 agent 列表(直接读 openclaw.json,不走 CLI,毫秒级响应)
|
||||
#[tauri::command]
|
||||
pub async fn list_agents() -> Result<Value, String> {
|
||||
@@ -83,6 +169,28 @@ pub async fn list_agents() -> Result<Value, String> {
|
||||
.map(|o| o.insert("workspace".to_string(), Value::String(ws)));
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 workspace 状态
|
||||
if let Some(ws_str) = agent.get("workspace").and_then(|w| w.as_str()) {
|
||||
let ws_path = std::path::Path::new(ws_str);
|
||||
let check_result = check_workspace_status(ws_path);
|
||||
|
||||
// 添加 workspaceStatus 字段
|
||||
agent.as_object_mut().map(|o| {
|
||||
o.insert(
|
||||
"workspaceStatus".to_string(),
|
||||
serde_json::to_value(&check_result.status).unwrap_or(Value::Null),
|
||||
)
|
||||
});
|
||||
|
||||
// 添加警告信息
|
||||
if let Some(w) = check_result.warning {
|
||||
agent
|
||||
.as_object_mut()
|
||||
.map(|o| o.insert("workspaceWarning".to_string(), Value::String(w)));
|
||||
}
|
||||
}
|
||||
|
||||
// 补全 identityName 用于前端显示
|
||||
let identity_name = agent
|
||||
.get("identity")
|
||||
@@ -105,6 +213,7 @@ pub async fn list_agents() -> Result<Value, String> {
|
||||
/// 创建新 agent(优先走 CLI,失败则直接写 openclaw.json 兜底)
|
||||
#[tauri::command]
|
||||
pub async fn add_agent(
|
||||
app: tauri::AppHandle,
|
||||
name: String,
|
||||
model: String,
|
||||
workspace: Option<String>,
|
||||
@@ -117,6 +226,18 @@ pub async fn add_agent(
|
||||
.join("workspace"),
|
||||
};
|
||||
|
||||
// 验证 workspace 路径有效性
|
||||
let ws_check = check_workspace_status(&ws);
|
||||
if let Some(ref warning) = ws_check.warning {
|
||||
eprintln!("[agent] Workspace 警告: {}", warning);
|
||||
}
|
||||
if ws_check.status.is_symlink && !ws_check.status.symlink_valid {
|
||||
return Err(format!(
|
||||
"指定的 workspace 是软链接,但目标不存在: {}",
|
||||
ws_check.status.symlink_target.as_deref().unwrap_or("未知")
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"agents".to_string(),
|
||||
"add".to_string(),
|
||||
@@ -152,21 +273,48 @@ pub async fn add_agent(
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[agent] CLI 超时 (15s)");
|
||||
eprintln!("[agent] CLI 超时 (15s),可能是 OpenClaw 未响应");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !cli_ok {
|
||||
// 兜底:直接写 openclaw.json
|
||||
add_agent_to_config(&name, &model, &ws)?;
|
||||
if let Err(e) = add_agent_to_config(&name, &model, &ws) {
|
||||
return Err(format!(
|
||||
"CLI 创建超时且配置写入失败: {}\n请尝试手动运行: openclaw agents add {} --workspace {}",
|
||||
e,
|
||||
name,
|
||||
ws.to_string_lossy()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 workspace 目录存在
|
||||
if !ws.exists() {
|
||||
let _ = fs::create_dir_all(&ws);
|
||||
if let Err(e) = fs::create_dir_all(&ws) {
|
||||
eprintln!("[agent] 创建 workspace 目录失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 验证步骤
|
||||
let agents = list_agents().await?;
|
||||
let created = agents.as_array().and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&name))
|
||||
});
|
||||
|
||||
if created.is_none() {
|
||||
eprintln!("[agent] 警告: Agent 创建后未在列表中出现");
|
||||
}
|
||||
|
||||
if !ws.exists() {
|
||||
eprintln!("[agent] 警告: Agent workspace 目录未创建");
|
||||
}
|
||||
|
||||
// 触发 Gateway 重载使新 agent 生效
|
||||
let _ = super::config::do_reload_gateway(&app).await;
|
||||
|
||||
list_agents().await
|
||||
}
|
||||
|
||||
@@ -229,7 +377,7 @@ fn add_agent_to_config(id: &str, model: &str, workspace: &std::path::Path) -> Re
|
||||
|
||||
/// 删除 agent(直接操作 openclaw.json + 删除 agent 目录,不走 CLI)
|
||||
#[tauri::command]
|
||||
pub async fn delete_agent(id: String) -> Result<String, String> {
|
||||
pub async fn delete_agent(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
||||
if id == "main" {
|
||||
return Err("不能删除默认 Agent".into());
|
||||
}
|
||||
@@ -265,15 +413,21 @@ pub async fn delete_agent(id: String) -> Result<String, String> {
|
||||
// 2. 删除 agent 目录(workspace + sessions 等)
|
||||
let agent_dir = super::openclaw_dir().join("agents").join(&id);
|
||||
if agent_dir.exists() {
|
||||
let _ = fs::remove_dir_all(&agent_dir);
|
||||
if let Err(e) = fs::remove_dir_all(&agent_dir) {
|
||||
eprintln!("[agent] 删除 agent 目录失败: {e},不影响配置删除");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 触发 Gateway 重载
|
||||
let _ = super::config::do_reload_gateway(&app).await;
|
||||
|
||||
Ok("已删除".into())
|
||||
}
|
||||
|
||||
/// 更新 agent 身份信息
|
||||
#[tauri::command]
|
||||
pub fn update_agent_identity(
|
||||
pub async fn update_agent_identity(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
emoji: Option<String>,
|
||||
@@ -333,7 +487,9 @@ pub fn update_agent_identity(
|
||||
});
|
||||
|
||||
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
if let Err(e) = fs::write(&path, json) {
|
||||
return Err(format!("写入配置失败: {e},请检查文件权限"));
|
||||
}
|
||||
|
||||
// 删除 IDENTITY.md 文件,让配置文件生效
|
||||
if let Some(ws_str) = workspace_path {
|
||||
@@ -343,6 +499,9 @@ pub fn update_agent_identity(
|
||||
}
|
||||
}
|
||||
|
||||
// 触发 Gateway 重载使配置生效
|
||||
let _ = super::config::do_reload_gateway(&app).await;
|
||||
|
||||
Ok("已更新".into())
|
||||
}
|
||||
|
||||
@@ -400,7 +559,11 @@ fn collect_dir_to_zip(
|
||||
|
||||
/// 更新 agent 模型配置
|
||||
#[tauri::command]
|
||||
pub fn update_agent_model(id: String, model: String) -> Result<String, String> {
|
||||
pub async fn update_agent_model(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
model: String,
|
||||
) -> Result<String, String> {
|
||||
let path = super::openclaw_dir().join("openclaw.json");
|
||||
let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
let mut config: Value =
|
||||
@@ -424,7 +587,12 @@ pub fn update_agent_model(id: String, model: String) -> Result<String, String> {
|
||||
.insert("model".to_string(), model_obj);
|
||||
|
||||
let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?;
|
||||
fs::write(&path, json).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
if let Err(e) = fs::write(&path, json) {
|
||||
return Err(format!("写入配置失败: {e},请检查文件权限"));
|
||||
}
|
||||
|
||||
// 触发 Gateway 重载使配置生效
|
||||
let _ = super::config::do_reload_gateway(&app).await;
|
||||
|
||||
Ok("已更新".into())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,12 @@ use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 缓存 gateway 端口,避免频繁读文件(5秒有效期)
|
||||
static GATEWAY_PORT_CACHE: std::sync::LazyLock<std::sync::Mutex<(u16, std::time::Instant)>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
std::sync::Mutex::new((18789, std::time::Instant::now() - Duration::from_secs(60)))
|
||||
});
|
||||
|
||||
pub mod agent;
|
||||
pub mod assistant;
|
||||
pub mod config;
|
||||
@@ -39,6 +45,41 @@ pub fn openclaw_dir() -> PathBuf {
|
||||
default_openclaw_dir()
|
||||
}
|
||||
|
||||
/// Gateway 监听端口:读取 `openclaw.json` 的 `gateway.port`,缺省 **18789**。
|
||||
/// 与面板「Gateway 配置」、服务状态检测(netstat / TCP / launchctl 兜底)共用同一来源,
|
||||
/// 并尊重 `clawpanel.json` 中的 `openclawDir` 自定义配置目录。
|
||||
pub fn gateway_listen_port() -> u16 {
|
||||
// 5秒内返回缓存值,避免服务状态检测时频繁读文件
|
||||
if let Ok(cache) = GATEWAY_PORT_CACHE.lock() {
|
||||
if cache.1.elapsed() < Duration::from_secs(5) {
|
||||
return cache.0;
|
||||
}
|
||||
}
|
||||
let port = read_gateway_port_from_config();
|
||||
if let Ok(mut cache) = GATEWAY_PORT_CACHE.lock() {
|
||||
*cache = (port, std::time::Instant::now());
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
fn read_gateway_port_from_config() -> u16 {
|
||||
let config_path = openclaw_dir().join("openclaw.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(port) = val
|
||||
.get("gateway")
|
||||
.and_then(|g| g.get("port"))
|
||||
.and_then(|p| p.as_u64())
|
||||
{
|
||||
if port > 0 && port < 65536 {
|
||||
return port as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18789
|
||||
}
|
||||
|
||||
fn panel_config_path() -> PathBuf {
|
||||
// ClawPanel 自身配置始终在默认目录,不随 openclawDir 变化
|
||||
default_openclaw_dir().join("clawpanel.json")
|
||||
@@ -341,16 +382,28 @@ fn build_enhanced_path() -> String {
|
||||
let mut extra: Vec<String> = vec![];
|
||||
|
||||
// 1. NVM_SYMLINK(nvm-windows 活跃版本符号链接,如 D:\nodejs)—— 最高优先级
|
||||
// 增强:尝试解析符号链接目标
|
||||
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
|
||||
let symlink_path = std::path::Path::new(&nvm_symlink);
|
||||
if symlink_path.is_dir() {
|
||||
extra.push(nvm_symlink.clone());
|
||||
}
|
||||
// 如果是符号链接,尝试读取其实际指向的目标
|
||||
#[cfg(target_os = "windows")]
|
||||
if symlink_path.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(symlink_path) {
|
||||
if target.is_dir() {
|
||||
extra.push(target.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. NVM_HOME(用户自定义 nvm 安装目录)
|
||||
if let Ok(nvm_home) = std::env::var("NVM_HOME") {
|
||||
let nvm_path = std::path::Path::new(&nvm_home);
|
||||
if nvm_path.is_dir() {
|
||||
// 扫描所有已安装的版本目录
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_path) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
@@ -359,13 +412,34 @@ fn build_enhanced_path() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尝试从 settings.json 读取当前激活版本
|
||||
let settings_path = nvm_path.join("settings.json");
|
||||
if settings_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// settings.json 中有 "path" 字段指向当前版本
|
||||
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
|
||||
{
|
||||
let version_path = nvm_path.join(current_version);
|
||||
if version_path.is_dir() {
|
||||
// 将当前激活版本移到更高优先级
|
||||
let version_bin = version_path.to_string_lossy().to_string();
|
||||
if !extra.contains(&version_bin) {
|
||||
extra.insert(0, version_bin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. %APPDATA%\nvm(nvm-windows 默认安装目录)
|
||||
if !appdata.is_empty() {
|
||||
extra.push(format!(r"{}\nvm", appdata));
|
||||
let nvm_dir = std::path::Path::new(&appdata).join("nvm");
|
||||
if nvm_dir.is_dir() {
|
||||
// 扫描所有已安装的版本
|
||||
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
@@ -374,10 +448,35 @@ fn build_enhanced_path() -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尝试从 settings.json 读取当前激活版本
|
||||
let settings_path = nvm_dir.join("settings.json");
|
||||
if settings_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(current_version) = json.get("path").and_then(|v| v.as_str())
|
||||
{
|
||||
let version_path = nvm_dir.join(current_version);
|
||||
if version_path.is_dir() {
|
||||
let version_bin = version_path.to_string_lossy().to_string();
|
||||
if !extra.contains(&version_bin) {
|
||||
extra.insert(0, version_bin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. volta
|
||||
extra.push(format!(r"{}\.volta\bin", home.display()));
|
||||
// volta 的活跃版本
|
||||
let volta_bin = std::path::Path::new(&home).join(".volta/bin");
|
||||
if volta_bin.is_dir() && !extra.contains(&volta_bin.to_string_lossy().to_string()) {
|
||||
extra.insert(0, volta_bin.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// 5. fnm
|
||||
if !localappdata.is_empty() {
|
||||
extra.push(format!(r"{}\fnm_multishells", localappdata));
|
||||
@@ -388,30 +487,53 @@ fn build_enhanced_path() -> String {
|
||||
.unwrap_or_else(|| std::path::Path::new(&appdata).join("fnm"));
|
||||
let fnm_versions = fnm_base.join("node-versions");
|
||||
if fnm_versions.is_dir() {
|
||||
// 尝试找到 fnm 的当前活跃版本
|
||||
let fnm_current = fnm_base.join("current");
|
||||
if fnm_current.is_dir() {
|
||||
let current_inst = fnm_current.join("installation");
|
||||
if current_inst.is_dir()
|
||||
&& current_inst.join("node.exe").exists()
|
||||
&& !extra.contains(¤t_inst.to_string_lossy().to_string())
|
||||
{
|
||||
extra.insert(0, current_inst.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
// 扫描所有版本
|
||||
if let Ok(entries) = std::fs::read_dir(&fnm_versions) {
|
||||
for entry in entries.flatten() {
|
||||
let inst = entry.path().join("installation");
|
||||
if inst.is_dir() && inst.join("node.exe").exists() {
|
||||
extra.push(inst.to_string_lossy().to_string());
|
||||
let inst_str = inst.to_string_lossy().to_string();
|
||||
if !extra.contains(&inst_str) {
|
||||
extra.push(inst_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. npm 全局(openclaw.cmd 通常在这里)
|
||||
if !appdata.is_empty() {
|
||||
extra.push(format!(r"{}\npm", appdata));
|
||||
}
|
||||
|
||||
// 7. 系统默认 Node.js 安装路径(优先级最低)
|
||||
extra.push(format!(r"{}\nodejs", pf));
|
||||
extra.push(format!(r"{}\nodejs", pf86));
|
||||
if !localappdata.is_empty() {
|
||||
extra.push(format!(r"{}\Programs\nodejs", localappdata));
|
||||
}
|
||||
|
||||
// 8. 扫描常见盘符下的 Node 安装(用户可能装在 D:\、F:\ 等)
|
||||
for drive in &["C", "D", "E", "F"] {
|
||||
extra.push(format!(r"{}:\nodejs", drive));
|
||||
extra.push(format!(r"{}:\Node", drive));
|
||||
extra.push(format!(r"{}:\Program Files\nodejs", drive));
|
||||
// 常见 AI/Dev 工具目录
|
||||
extra.push(format!(r"{}:\AI\Node", drive));
|
||||
extra.push(format!(r"{}:\AI\nodejs", drive));
|
||||
extra.push(format!(r"{}:\Dev\nodejs", drive));
|
||||
extra.push(format!(r"{}:\Tools\nodejs", drive));
|
||||
}
|
||||
|
||||
let mut parts: Vec<&str> = vec![];
|
||||
@@ -419,9 +541,10 @@ fn build_enhanced_path() -> String {
|
||||
if let Some(ref cp) = custom_path {
|
||||
parts.push(cp.as_str());
|
||||
}
|
||||
// 然后是默认扫描到的路径
|
||||
// 然后是默认扫描到的路径(去重)
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for p in &extra {
|
||||
if std::path::Path::new(p).exists() {
|
||||
if std::path::Path::new(p).exists() && seen.insert(p.clone()) {
|
||||
parts.push(p.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
use crate::utils::openclaw_command_async;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unused_imports)]
|
||||
@@ -22,7 +23,20 @@ pub async fn skills_list() -> Result<Value, String> {
|
||||
// CLI 可能在有 skill 缺依赖时返回非零退出码,但 JSON 输出仍然有效
|
||||
// 优先尝试解析 JSON,无论退出码
|
||||
match extract_json(&stdout) {
|
||||
Some(v) => Ok(v),
|
||||
Some(mut v) => {
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("cliAvailable".into(), Value::Bool(true));
|
||||
obj.insert(
|
||||
"diagnostic".into(),
|
||||
serde_json::json!({
|
||||
"status": "ok",
|
||||
"message": "已使用 OpenClaw CLI 结果",
|
||||
"exitCode": o.status.code().unwrap_or(0),
|
||||
}),
|
||||
);
|
||||
}
|
||||
merge_local_skills(v)
|
||||
}
|
||||
None => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
eprintln!(
|
||||
@@ -31,14 +45,27 @@ pub async fn skills_list() -> Result<Value, String> {
|
||||
stdout.chars().take(200).collect::<String>(),
|
||||
stderr.chars().take(200).collect::<String>()
|
||||
);
|
||||
scan_local_skills()
|
||||
scan_local_skills(Some(serde_json::json!({
|
||||
"status": "parse-failed",
|
||||
"message": "OpenClaw CLI 可执行,但返回结果未能解析为 JSON,当前展示本地扫描结果",
|
||||
"cliAvailable": true,
|
||||
"exitCode": o.status.code().unwrap_or(-1),
|
||||
"stderr": stderr.chars().take(200).collect::<String>(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// CLI 不可用或超时,兜底扫描本地 skills 目录
|
||||
scan_local_skills()
|
||||
}
|
||||
Ok(Err(e)) => scan_local_skills(Some(serde_json::json!({
|
||||
"status": "exec-failed",
|
||||
"message": format!("调用 OpenClaw CLI 失败,当前展示本地扫描结果: {e}"),
|
||||
"cliAvailable": false,
|
||||
}))),
|
||||
Err(_) => scan_local_skills(Some(serde_json::json!({
|
||||
"status": "timeout",
|
||||
"message": "OpenClaw CLI 调用超时,当前展示本地扫描结果",
|
||||
"cliAvailable": true,
|
||||
"timeoutSeconds": 15,
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +79,22 @@ pub async fn skills_info(name: String) -> Result<Value, String> {
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
if let Some(local) = scan_custom_skill_detail(&name) {
|
||||
return Ok(local);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("获取详情失败: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
extract_json(&stdout).ok_or_else(|| "解析详情失败: 输出中未找到有效 JSON".to_string())
|
||||
let parsed =
|
||||
extract_json(&stdout).ok_or_else(|| "解析详情失败: 输出中未找到有效 JSON".to_string())?;
|
||||
if parsed.get("error").and_then(|v| v.as_str()) == Some("not found") {
|
||||
if let Some(local) = scan_custom_skill_detail(&name) {
|
||||
return Ok(local);
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// 检查 Skills 依赖状态(openclaw skills check --json)
|
||||
@@ -472,7 +509,8 @@ pub async fn skills_uninstall(name: String) -> Result<Value, String> {
|
||||
if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') {
|
||||
return Err("无效的 Skill 名称".to_string());
|
||||
}
|
||||
let skills_dir = super::openclaw_dir().join("skills").join(&name);
|
||||
let skills_dir =
|
||||
resolve_custom_skill_dir(&name).ok_or_else(|| format!("Skill「{name}」不存在"))?;
|
||||
if !skills_dir.exists() {
|
||||
return Err(format!("Skill「{name}」不存在"));
|
||||
}
|
||||
@@ -480,26 +518,240 @@ pub async fn skills_uninstall(name: String) -> Result<Value, String> {
|
||||
Ok(serde_json::json!({ "success": true, "name": name }))
|
||||
}
|
||||
|
||||
/// 验证 Skill 配置是否正确
|
||||
#[tauri::command]
|
||||
pub async fn skills_validate(name: String) -> Result<Value, String> {
|
||||
if name.is_empty() || name.contains("..") || name.contains('/') || name.contains('\\') {
|
||||
return Err("无效的 Skill 名称".to_string());
|
||||
}
|
||||
|
||||
let skill_dir =
|
||||
resolve_custom_skill_dir(&name).ok_or_else(|| format!("Skill「{name}」不存在"))?;
|
||||
if !skill_dir.exists() {
|
||||
return Err(format!("Skill「{name}」不存在"));
|
||||
}
|
||||
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let package_json = skill_dir.join("package.json");
|
||||
|
||||
let mut issues: Vec<Value> = Vec::new();
|
||||
let mut warnings: Vec<Value> = Vec::new();
|
||||
let mut passed: Vec<String> = Vec::new();
|
||||
|
||||
// 1. 检查 SKILL.md 是否存在
|
||||
if !skill_md.exists() {
|
||||
issues.push(serde_json::json!({
|
||||
"level": "error",
|
||||
"code": "MISSING_SKILL_MD",
|
||||
"message": "缺少 SKILL.md 文件",
|
||||
"suggestion": "创建 SKILL.md 文件,包含 skill 的描述和使用说明"
|
||||
}));
|
||||
} else {
|
||||
passed.push("SKILL.md 存在".to_string());
|
||||
|
||||
// 2. 检查 SKILL.md frontmatter 格式
|
||||
if let Some(frontmatter) = parse_skill_frontmatter(&skill_md) {
|
||||
// 检查必要字段
|
||||
let required_fields = ["description", "fullPath"];
|
||||
for field in &required_fields {
|
||||
if !frontmatter
|
||||
.get(*field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
issues.push(serde_json::json!({
|
||||
"level": "error",
|
||||
"code": "MISSING_REQUIRED_FIELD",
|
||||
"message": format!("SKILL.md frontmatter 缺少必要字段: {}", field),
|
||||
"field": field,
|
||||
"suggestion": format!("在 frontmatter 中添加 {}: <值>", field)
|
||||
}));
|
||||
} else {
|
||||
passed.push(format!("frontmatter.{} 字段存在且非空", field));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 fullPath 格式(应该是绝对路径或 ~ 开头)
|
||||
if let Some(fp) = frontmatter.get("fullPath").and_then(|v| v.as_str()) {
|
||||
// Windows 路径以盘符开头(如 C:\),Unix 以 / 或 ~ 或 . 开头
|
||||
let is_valid_path = fp.starts_with('/')
|
||||
|| fp.starts_with('~')
|
||||
|| fp.starts_with('.')
|
||||
|| (fp.len() >= 3
|
||||
&& fp.as_bytes()[1] == b':'
|
||||
&& (fp.as_bytes()[2] == b'\\' || fp.as_bytes()[2] == b'/'));
|
||||
if !is_valid_path {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "INVALID_FULLPATH_FORMAT",
|
||||
"message": format!("fullPath 格式可能不正确: {}", fp),
|
||||
"suggestion": "建议使用绝对路径或 ~ 开头"
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
issues.push(serde_json::json!({
|
||||
"level": "error",
|
||||
"code": "INVALID_FRONTMATTER",
|
||||
"message": "SKILL.md frontmatter 格式不正确",
|
||||
"suggestion": "确保 frontmatter 以 --- 开头和结尾,包含正确的 YAML 格式"
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. 检查 SKILL.md 内容(非 frontmatter 部分)
|
||||
if let Ok(content) = std::fs::read_to_string(&skill_md) {
|
||||
// 检查是否有空内容
|
||||
let body = content
|
||||
.split("---")
|
||||
.skip(2) // 跳过 frontmatter
|
||||
.collect::<Vec<_>>()
|
||||
.join("---")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if body.len() < 10 {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "EMPTY_SKILL_CONTENT",
|
||||
"message": "SKILL.md 正文内容为空或过短",
|
||||
"suggestion": "添加 skill 的使用说明、功能描述等详细内容"
|
||||
}));
|
||||
} else {
|
||||
passed.push("SKILL.md 正文内容完整".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查 package.json
|
||||
if !package_json.exists() {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "MISSING_PACKAGE_JSON",
|
||||
"message": "缺少 package.json 文件",
|
||||
"suggestion": "可选:创建 package.json 以便管理 npm 依赖"
|
||||
}));
|
||||
} else {
|
||||
passed.push("package.json 存在".to_string());
|
||||
|
||||
// 5. 解析并验证 package.json
|
||||
if let Ok(pkg_content) = std::fs::read_to_string(&package_json) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&pkg_content) {
|
||||
// 检查 name 字段
|
||||
if let Some(pkg_name) = pkg.get("name").and_then(|v| v.as_str()) {
|
||||
if pkg_name != name {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "NAME_MISMATCH",
|
||||
"message": format!("package.json 中的 name '{}' 与目录名 '{}' 不一致", pkg_name, name),
|
||||
"suggestion": "确保 package.json 的 name 字段与 skill 目录名一致"
|
||||
}));
|
||||
} else {
|
||||
passed.push("package.json.name 与目录名一致".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 dependencies 和 node_modules
|
||||
if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) {
|
||||
let deps_count = deps.len();
|
||||
passed.push(format!("package.json 声明了 {} 个依赖", deps_count));
|
||||
|
||||
// 检查 node_modules
|
||||
let node_modules = skill_dir.join("node_modules");
|
||||
if node_modules.exists() {
|
||||
let missing = detect_missing_dependencies(
|
||||
&deps.keys().cloned().collect::<Vec<_>>(),
|
||||
&skill_dir,
|
||||
);
|
||||
if !missing.is_empty() {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "MISSING_NPM_DEPS",
|
||||
"message": format!("缺少 {} 个 npm 依赖: {}", missing.len(), missing.join(", ")),
|
||||
"missingDeps": missing,
|
||||
"suggestion": "运行 npm install 安装依赖"
|
||||
}));
|
||||
} else {
|
||||
passed.push("所有 npm 依赖已安装".to_string());
|
||||
}
|
||||
} else if deps_count > 0 {
|
||||
issues.push(serde_json::json!({
|
||||
"level": "error",
|
||||
"code": "NODE_MODULES_MISSING",
|
||||
"message": "package.json 声明了依赖但 node_modules 不存在",
|
||||
"suggestion": "运行 npm install 安装依赖"
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
issues.push(serde_json::json!({
|
||||
"level": "error",
|
||||
"code": "INVALID_PACKAGE_JSON",
|
||||
"message": "package.json 格式不正确",
|
||||
"suggestion": "确保 package.json 是有效的 JSON 格式"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 检查常见的不应该存在的文件
|
||||
let unnecessary_files = ["README.md", "README.txt", "readme.md"];
|
||||
for file in unnecessary_files {
|
||||
let file_path = skill_dir.join(file);
|
||||
if file_path.exists() {
|
||||
warnings.push(serde_json::json!({
|
||||
"level": "warning",
|
||||
"code": "UNNECESSARY_FILE",
|
||||
"message": format!("发现不必要的文件: {}", file),
|
||||
"suggestion": "Skill 文档应放在 SKILL.md 中,删除 README.md"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
let has_errors = !issues.is_empty();
|
||||
let is_valid = !has_errors;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"valid": is_valid,
|
||||
"summary": {
|
||||
"errors": issues.len(),
|
||||
"warnings": warnings.len(),
|
||||
"passed": passed.len()
|
||||
},
|
||||
"issues": issues,
|
||||
"warnings": warnings,
|
||||
"passed": passed,
|
||||
"validatedAt": chrono::Utc::now().to_rfc3339()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Public wrapper for extract_json, used by config.rs get_status_summary
|
||||
pub fn extract_json_pub(text: &str) -> Option<Value> {
|
||||
extract_json(text)
|
||||
}
|
||||
|
||||
/// Extract the first valid JSON object or array from a string that may contain
|
||||
/// non-JSON lines (Node.js warnings, npm update prompts, etc.)
|
||||
/// non-JSON lines (Node.js warnings, npm update prompts, ANSI codes, etc.)
|
||||
fn extract_json(text: &str) -> Option<Value> {
|
||||
// Pre-processing: clean up common CLI output artifacts
|
||||
let cleaned = clean_cli_output(text);
|
||||
|
||||
// Try parsing the whole string first (fast path)
|
||||
if let Ok(v) = serde_json::from_str::<Value>(text) {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
// Find the first '{' or '[' and try parsing from there
|
||||
for (i, ch) in text.char_indices() {
|
||||
for (i, ch) in cleaned.char_indices() {
|
||||
if ch == '{' || ch == '[' {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&text[i..]) {
|
||||
// Try direct parsing first
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&cleaned[i..]) {
|
||||
return Some(v);
|
||||
}
|
||||
// Try with a streaming deserializer to handle trailing content
|
||||
let mut de = serde_json::Deserializer::from_str(&text[i..]).into_iter::<Value>();
|
||||
let mut de = serde_json::Deserializer::from_str(&cleaned[i..]).into_iter::<Value>();
|
||||
if let Some(Ok(v)) = de.next() {
|
||||
return Some(v);
|
||||
}
|
||||
@@ -508,71 +760,447 @@ fn extract_json(text: &str) -> Option<Value> {
|
||||
None
|
||||
}
|
||||
|
||||
/// CLI 不可用时的兜底:扫描 ~/.openclaw/skills 目录
|
||||
fn scan_local_skills() -> Result<Value, String> {
|
||||
let skills_dir = super::openclaw_dir().join("skills");
|
||||
if !skills_dir.exists() {
|
||||
/// Clean up CLI output by removing common non-JSON artifacts:
|
||||
/// - ANSI escape sequences (color codes)
|
||||
/// - npm/node progress bars
|
||||
/// - Multiple leading/trailing whitespace
|
||||
/// - Debug log prefixes
|
||||
fn clean_cli_output(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// 1. Remove ANSI escape sequences
|
||||
// Common patterns: \x1b[...m, \x1b[...;...m, ESC[...m
|
||||
let ansi_regex = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
|
||||
result = ansi_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// 2. Remove npm/node progress bar characters
|
||||
// Pattern: ████░░░░░░ 50% | some info
|
||||
let progress_regex = regex::Regex::new(r"[█▓▒░│┼┤├┬┴]+[│].*?\r?\n").unwrap();
|
||||
result = progress_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// 3. Remove lines that are purely ANSI cursor control sequences
|
||||
// Like \r (carriage return for overwriting), \x1b[?25l (hide cursor), etc.
|
||||
let cursor_regex = regex::Regex::new(r"\x1b\[[?][0-9]+[a-zA-Z]").unwrap();
|
||||
result = cursor_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// 4. Remove "Download" / "Installing" progress prefixes common in npm
|
||||
let npm_progress_regex = regex::Regex::new(r"^\s*(added|removed|changed|up to date)?\s*\d+\s*(package)?s?\s*(in\s+\d+s)?\s*(✓|✔|:)?\s*\r?$").unwrap();
|
||||
result = npm_progress_regex.replace_all(&result, "").to_string();
|
||||
|
||||
// 5. Normalize line endings and remove empty lines at the start
|
||||
let lines: Vec<&str> = result
|
||||
.lines()
|
||||
.map(|l| l.trim_end_matches(['\r', '\n']))
|
||||
.collect();
|
||||
|
||||
// Skip leading empty/whitespace-only lines
|
||||
let start_idx = lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(0);
|
||||
let relevant_lines = &lines[start_idx..];
|
||||
|
||||
// 6. Find the first line that starts JSON (fast path)
|
||||
for line in relevant_lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Otherwise, rejoin and let extract_json handle it
|
||||
result
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn custom_skill_roots() -> Vec<(std::path::PathBuf, &'static str)> {
|
||||
let mut roots = vec![(super::openclaw_dir().join("skills"), "OpenClaw 自定义")];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let claude_skills = home.join(".claude").join("skills");
|
||||
if !roots.iter().any(|(dir, _)| dir == &claude_skills) {
|
||||
roots.push((claude_skills, "Claude 自定义"));
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn resolve_custom_skill_dir(name: &str) -> Option<std::path::PathBuf> {
|
||||
custom_skill_roots()
|
||||
.into_iter()
|
||||
.map(|(root, _)| root.join(name))
|
||||
.find(|path| path.exists())
|
||||
}
|
||||
|
||||
fn scan_custom_skill_detail(name: &str) -> Option<Value> {
|
||||
for (root, source_label) in custom_skill_roots() {
|
||||
let skill_path = root.join(name);
|
||||
if !skill_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let base = scan_single_skill(&skill_path, name);
|
||||
let missing_deps = base
|
||||
.get("missingDeps")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let eligible = base.get("ready").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let mut detail = serde_json::json!({
|
||||
"name": name,
|
||||
"description": base.get("description").cloned().unwrap_or(Value::String(String::new())),
|
||||
"emoji": base.get("emoji").cloned().unwrap_or(Value::String("🧩".to_string())),
|
||||
"eligible": eligible,
|
||||
"disabled": false,
|
||||
"blockedByAllowlist": false,
|
||||
"source": source_label,
|
||||
"bundled": false,
|
||||
"filePath": skill_path.to_string_lossy().to_string(),
|
||||
"homepage": base.get("homepage").cloned().unwrap_or(Value::Null),
|
||||
"version": base.get("version").cloned().unwrap_or(Value::Null),
|
||||
"author": base.get("author").cloned().unwrap_or(Value::Null),
|
||||
"dependencies": base.get("dependencies").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"missingDeps": Value::Array(missing_deps.clone()),
|
||||
"missing": {
|
||||
"bins": [],
|
||||
"anyBins": [],
|
||||
"env": [],
|
||||
"config": [],
|
||||
"os": []
|
||||
},
|
||||
"requirements": {
|
||||
"bins": [],
|
||||
"env": [],
|
||||
"config": []
|
||||
},
|
||||
"install": []
|
||||
});
|
||||
|
||||
if let Some(full_path) = base.get("fullPath").cloned() {
|
||||
detail["fullPath"] = full_path;
|
||||
}
|
||||
|
||||
return Some(detail);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn merge_local_skills(mut data: Value) -> Result<Value, String> {
|
||||
let local_skills = scan_local_skill_entries()?;
|
||||
let Some(skills) = data.get_mut("skills").and_then(|v| v.as_array_mut()) else {
|
||||
return Ok(data);
|
||||
};
|
||||
|
||||
let mut existing = HashSet::new();
|
||||
for item in skills.iter() {
|
||||
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
|
||||
existing.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for skill in local_skills {
|
||||
if let Some(name) = skill.get("name").and_then(|v| v.as_str()) {
|
||||
if existing.insert(name.to_string()) {
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn scan_local_skill_entries() -> Result<Vec<Value>, String> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
for (skills_dir, source_label) in custom_skill_roots() {
|
||||
if !skills_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&skills_dir).map_err(|e| {
|
||||
format!(
|
||||
"读取 Skills 目录失败 ({}): {e}",
|
||||
skills_dir.to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() && !file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let base = scan_single_skill(&entry.path(), &name);
|
||||
let eligible = base.get("ready").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mut item = serde_json::json!({
|
||||
"name": name,
|
||||
"description": base.get("description").cloned().unwrap_or(Value::String(String::new())),
|
||||
"emoji": base.get("emoji").cloned().unwrap_or(Value::String("🧩".to_string())),
|
||||
"eligible": eligible,
|
||||
"disabled": false,
|
||||
"blockedByAllowlist": false,
|
||||
"source": source_label,
|
||||
"bundled": false,
|
||||
"filePath": entry.path().to_string_lossy().to_string(),
|
||||
"homepage": base.get("homepage").cloned().unwrap_or(Value::Null),
|
||||
"missing": {
|
||||
"bins": [],
|
||||
"anyBins": [],
|
||||
"env": [],
|
||||
"config": [],
|
||||
"os": []
|
||||
},
|
||||
"missingDeps": base.get("missingDeps").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"install": []
|
||||
});
|
||||
|
||||
if let Some(full_path) = base.get("fullPath").cloned() {
|
||||
item["fullPath"] = full_path;
|
||||
}
|
||||
|
||||
skills.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
skills.sort_by(|a, b| {
|
||||
let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
an.cmp(bn)
|
||||
});
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// CLI 不可用或当前结果不可用时的兜底:扫描本地自定义 Skills 目录(含 ~/.openclaw/skills 与 ~/.claude/skills)
|
||||
fn scan_local_skills(cli_diagnostic: Option<Value>) -> Result<Value, String> {
|
||||
let roots = custom_skill_roots();
|
||||
let scanned_roots: Vec<String> = roots
|
||||
.iter()
|
||||
.map(|(dir, label)| format!("{}: {}", label, dir.to_string_lossy()))
|
||||
.collect();
|
||||
let skills = scan_local_skill_entries()?;
|
||||
let cli_available = cli_diagnostic
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("cliAvailable"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if skills.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"skills": [],
|
||||
"source": "local-scan",
|
||||
"cliAvailable": false
|
||||
"cliAvailable": cli_available,
|
||||
"diagnostic": {
|
||||
"status": cli_diagnostic.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()).unwrap_or("no-skills-dir"),
|
||||
"message": "未在本地自定义目录中发现 Skills",
|
||||
"scannedRoots": scanned_roots,
|
||||
"cli": cli_diagnostic
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !ft.is_dir() && !ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let skill_md = entry.path().join("SKILL.md");
|
||||
let description = if skill_md.exists() {
|
||||
// 尝试从 SKILL.md 的 frontmatter 中提取 description
|
||||
parse_skill_description(&skill_md)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
skills.push(serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source": "managed",
|
||||
"eligible": true,
|
||||
"bundled": false,
|
||||
"filePath": skill_md.to_string_lossy(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 统计信息
|
||||
let total = skills.len();
|
||||
let ready_count = skills
|
||||
.iter()
|
||||
.filter(|s| s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.count();
|
||||
let missing_deps_count = skills
|
||||
.iter()
|
||||
.filter(|s| !s.get("eligible").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.count();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"skills": skills,
|
||||
"source": "local-scan",
|
||||
"cliAvailable": false
|
||||
"cliAvailable": cli_available,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"ready": ready_count,
|
||||
"missingDeps": missing_deps_count,
|
||||
},
|
||||
"diagnostic": {
|
||||
"status": cli_diagnostic.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()).unwrap_or("scanned"),
|
||||
"scannedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"scannedRoots": scanned_roots,
|
||||
"cli": cli_diagnostic
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 SKILL.md 的 YAML frontmatter 中提取 description
|
||||
fn parse_skill_description(path: &std::path::Path) -> String {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
// frontmatter 格式: ---\n...\n---
|
||||
if !content.starts_with("---") {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(end) = content[3..].find("---") {
|
||||
let fm = &content[3..3 + end];
|
||||
for line in fm.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("description:") {
|
||||
return rest.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
/// 扫描单个 Skill 的详细信息
|
||||
fn scan_single_skill(skill_path: &std::path::Path, name: &str) -> Value {
|
||||
let mut result = serde_json::json!({
|
||||
"name": name,
|
||||
"source": "managed",
|
||||
"bundled": false,
|
||||
"filePath": skill_path.to_string_lossy(),
|
||||
"ready": false,
|
||||
"missingDeps": [],
|
||||
"installedDeps": [],
|
||||
});
|
||||
|
||||
// 1. 检查必要文件
|
||||
let skill_md = skill_path.join("SKILL.md");
|
||||
let package_json = skill_path.join("package.json");
|
||||
|
||||
let has_skill_md = skill_md.exists();
|
||||
let has_package_json = package_json.exists();
|
||||
|
||||
result["hasSkillMd"] = Value::Bool(has_skill_md);
|
||||
result["hasPackageJson"] = Value::Bool(has_package_json);
|
||||
|
||||
// 2. 解析 package.json 获取更多信息
|
||||
if has_package_json {
|
||||
if let Ok(pkg_content) = std::fs::read_to_string(&package_json) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&pkg_content) {
|
||||
// 提取基本信息
|
||||
if let Some(version) = pkg.get("version").and_then(|v| v.as_str()) {
|
||||
result["version"] = Value::String(version.to_string());
|
||||
}
|
||||
if let Some(author) = pkg.get("author").and_then(|v| {
|
||||
v.as_str().or_else(|| {
|
||||
v.as_object()
|
||||
.and_then(|o| o.get("name").and_then(|n| n.as_str()))
|
||||
})
|
||||
}) {
|
||||
result["author"] = Value::String(author.to_string());
|
||||
}
|
||||
if let Some(desc) = pkg.get("description").and_then(|v| v.as_str()) {
|
||||
result["description"] = Value::String(desc.to_string());
|
||||
}
|
||||
if let Some(homepage) = pkg.get("homepage").and_then(|v| v.as_str()) {
|
||||
result["homepage"] = Value::String(homepage.to_string());
|
||||
}
|
||||
|
||||
// 提取 dependencies
|
||||
if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_object()) {
|
||||
let deps_list: Vec<String> = deps.keys().cloned().collect();
|
||||
result["dependencies"] =
|
||||
Value::Array(deps_list.iter().map(|s| Value::String(s.clone())).collect());
|
||||
|
||||
// 检测缺少的依赖(简化版:通过检查 node_modules)
|
||||
let missing_deps = detect_missing_dependencies(&deps_list, skill_path);
|
||||
result["missingDeps"] = Value::Array(
|
||||
missing_deps
|
||||
.iter()
|
||||
.map(|s| Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
result["installedDeps"] = Value::Array(
|
||||
deps_list
|
||||
.iter()
|
||||
.filter(|d| !missing_deps.contains(d))
|
||||
.map(|s| Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
// 提取 scripts(可能包含 install 后处理等)
|
||||
if let Some(scripts) = pkg.get("scripts").and_then(|v| v.as_object()) {
|
||||
let script_names: Vec<String> = scripts.keys().cloned().collect();
|
||||
result["scripts"] = Value::Array(
|
||||
script_names
|
||||
.iter()
|
||||
.map(|s| Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
|
||||
// 3. 从 SKILL.md frontmatter 提取额外信息
|
||||
if has_skill_md {
|
||||
if let Some(frontmatter) = parse_skill_frontmatter(&skill_md) {
|
||||
// 覆盖或补充 description(SKILL.md 的 description 更权威)
|
||||
if let Some(desc) = frontmatter.get("description").and_then(|v| v.as_str()) {
|
||||
result["description"] = Value::String(desc.to_string());
|
||||
}
|
||||
if let Some(full_path) = frontmatter.get("fullPath").and_then(|v| v.as_str()) {
|
||||
result["fullPath"] = Value::String(full_path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 判断 ready 状态
|
||||
// Skill ready 需要:1) 有 SKILL.md 2) 没有缺少依赖 3) 依赖已安装
|
||||
let has_all_deps = result["missingDeps"]
|
||||
.as_array()
|
||||
.map(|a| a.is_empty())
|
||||
.unwrap_or(true);
|
||||
let has_essential_files = has_skill_md;
|
||||
result["ready"] = Value::Bool(has_essential_files && has_all_deps);
|
||||
|
||||
// 5. 检测是否有 node_modules(npm 包已安装)
|
||||
let node_modules = skill_path.join("node_modules");
|
||||
result["nodeModulesInstalled"] = Value::Bool(node_modules.exists());
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 检测缺少的依赖
|
||||
fn detect_missing_dependencies(deps: &[String], skill_path: &std::path::Path) -> Vec<String> {
|
||||
let node_modules = skill_path.join("node_modules");
|
||||
if !node_modules.exists() {
|
||||
// node_modules 不存在,所有依赖都算缺失
|
||||
return deps.to_vec();
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for dep in deps {
|
||||
let dep_path = node_modules.join(dep);
|
||||
// 检查依赖目录或 @scope/package 格式
|
||||
if !dep_path.exists() {
|
||||
// 可能是 @scope/package 格式,直接检查目录
|
||||
missing.push(dep.clone());
|
||||
}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// 解析 SKILL.md frontmatter,返回键值对
|
||||
fn parse_skill_frontmatter(path: &std::path::Path) -> Option<Value> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
// frontmatter 格式: ---\n...\n---
|
||||
if !content.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let after_first = content[3..].find("---")?;
|
||||
|
||||
let fm_content = &content[3..3 + after_first];
|
||||
let mut fm_map = serde_json::Map::new();
|
||||
|
||||
for line in fm_content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || !trimmed.contains(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(colon_pos) = trimmed.find(':') {
|
||||
let key = trimmed[..colon_pos].trim().to_string();
|
||||
let value = trimmed[colon_pos + 1..].trim();
|
||||
|
||||
// 处理引号包裹的值
|
||||
let clean_value = value.trim_matches('"').trim_matches('\'').trim();
|
||||
|
||||
if !key.is_empty() && !clean_value.is_empty() {
|
||||
fm_map.insert(key, Value::String(clean_value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fm_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(fm_map))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ pub fn run() {
|
||||
// 配置
|
||||
config::read_openclaw_config,
|
||||
config::write_openclaw_config,
|
||||
config::validate_openclaw_config,
|
||||
config::read_mcp_config,
|
||||
config::write_mcp_config,
|
||||
config::get_version_info,
|
||||
@@ -160,10 +161,20 @@ pub fn run() {
|
||||
messaging::remove_messaging_platform,
|
||||
messaging::toggle_messaging_platform,
|
||||
messaging::verify_bot_token,
|
||||
messaging::diagnose_channel,
|
||||
messaging::repair_qqbot_channel_setup,
|
||||
messaging::list_configured_platforms,
|
||||
messaging::get_channel_plugin_status,
|
||||
messaging::install_channel_plugin,
|
||||
messaging::install_qqbot_plugin,
|
||||
messaging::run_channel_action,
|
||||
messaging::check_weixin_plugin_status,
|
||||
// Agent 渠道绑定管理
|
||||
messaging::get_agent_bindings,
|
||||
messaging::list_all_bindings,
|
||||
messaging::save_agent_binding,
|
||||
messaging::delete_agent_binding,
|
||||
messaging::delete_agent_all_bindings,
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
skills::skills_list,
|
||||
skills::skills_info,
|
||||
@@ -176,6 +187,7 @@ pub fn run() {
|
||||
skills::skills_clawhub_search,
|
||||
skills::skills_clawhub_install,
|
||||
skills::skills_uninstall,
|
||||
skills::skills_validate,
|
||||
// 前端热更新
|
||||
update::check_frontend_update,
|
||||
update::download_frontend_update,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.9.7",
|
||||
"version": "0.9.8",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -222,8 +222,7 @@ export function renderSidebar(el) {
|
||||
// 主题切换
|
||||
const themeBtn = e.target.closest('#btn-theme-toggle')
|
||||
if (themeBtn) {
|
||||
toggleTheme()
|
||||
renderSidebar(el)
|
||||
toggleTheme(() => renderSidebar(el))
|
||||
return
|
||||
}
|
||||
// 实例切换器
|
||||
|
||||
@@ -21,7 +21,11 @@ export function toast(message, type = 'info', options = {}) {
|
||||
el.className = `toast ${type}`
|
||||
|
||||
const textSpan = document.createElement('span')
|
||||
textSpan.textContent = message
|
||||
if (options.html) {
|
||||
textSpan.innerHTML = message
|
||||
} else {
|
||||
textSpan.textContent = message
|
||||
}
|
||||
el.appendChild(textSpan)
|
||||
|
||||
// 如果有操作按钮,添加到 toast 中
|
||||
|
||||
@@ -146,7 +146,7 @@ function _setGatewayRunning(val) {
|
||||
if (val) {
|
||||
// 仅记录恢复运行时间,避免短暂存活就把重启计数清零
|
||||
_gatewayRunningSince = Date.now()
|
||||
} else if (!isTauri && wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
|
||||
} else if (wasRunning && !_userStopped && !_isUpgrading && _openclawReady) {
|
||||
_gatewayRunningSince = 0
|
||||
// Gateway 意外停止,尝试自动重启
|
||||
_tryAutoRestart()
|
||||
@@ -173,6 +173,20 @@ async function _tryAutoRestart() {
|
||||
return
|
||||
}
|
||||
|
||||
// 重启前再次确认端口确实空闲,防止端口被其他程序占用时无限拉起
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
const gw = services?.[0]
|
||||
if (gw?.running) {
|
||||
console.log('[guardian] 端口仍在使用中,跳过自动重启')
|
||||
_gwStopCount = 0
|
||||
_gatewayRunning = true
|
||||
_gatewayRunningSince = Date.now()
|
||||
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
_autoRestartCount = decision.autoRestartCount
|
||||
_lastRestartTime = decision.lastRestartTime
|
||||
console.log(`[guardian] Gateway 意外停止,自动重启 (${_autoRestartCount}/3)...`)
|
||||
@@ -217,10 +231,10 @@ export async function refreshGatewayStatus() {
|
||||
}
|
||||
|
||||
let _pollTimer = null
|
||||
/** 启动 Gateway 状态轮询(每 15 秒,避免过于频繁) */
|
||||
/** 启动 Gateway 状态轮询(每 15 秒检测一次) */
|
||||
export function startGatewayPoll() {
|
||||
if (_pollTimer) return
|
||||
_pollTimer = setInterval(() => refreshGatewayStatus(), 30000)
|
||||
_pollTimer = setInterval(() => refreshGatewayStatus(), 15000)
|
||||
}
|
||||
export function stopGatewayPoll() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
|
||||
|
||||
22
src/lib/channel-labels.js
Normal file
22
src/lib/channel-labels.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/** 渠道 key → 中文显示名(供多页面复用) */
|
||||
export const CHANNEL_LABELS = {
|
||||
qqbot: 'QQ 机器人',
|
||||
telegram: 'Telegram',
|
||||
feishu: '飞书',
|
||||
dingtalk: '钉钉',
|
||||
'dingtalk-connector': '钉钉',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
whatsapp: 'WhatsApp',
|
||||
msteams: 'Microsoft Teams',
|
||||
signal: 'Signal',
|
||||
matrix: 'Matrix',
|
||||
irc: 'IRC',
|
||||
googlechat: 'Google Chat',
|
||||
imessage: 'iMessage',
|
||||
line: 'LINE',
|
||||
nostr: 'Nostr',
|
||||
mattermost: 'Mattermost',
|
||||
'openclaw-weixin': '微信',
|
||||
weixin: '微信',
|
||||
}
|
||||
@@ -48,6 +48,9 @@ const PATHS = {
|
||||
'lightbulb': '<line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0018 8 6 6 0 006 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 018.91 14"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
|
||||
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
'hash': '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
|
||||
'phone': '<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
|
||||
// 军事主题图标
|
||||
|
||||
@@ -240,7 +240,8 @@ function inlineFormat(text) {
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>')
|
||||
// 避免 (?<!\w) 负向后查找:旧版 Safari / 部分 WebView 会报 invalid group specifier name
|
||||
.replace(/(^|[^A-Za-z0-9_])_(.+?)_(?![A-Za-z0-9_])/g, '$1<em>$2</em>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||
const safeSrc = resolveImageSrc(src.trim())
|
||||
const escapedSrc = escapeHtml(src).replace(/\\/g, '\')
|
||||
|
||||
@@ -31,7 +31,7 @@ export const PROVIDER_PRESETS = [
|
||||
// 晴辰云配置
|
||||
export const QTCOOL = {
|
||||
baseUrl: 'https://gpt.qt.cool/v1',
|
||||
defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc',
|
||||
defaultKey: '',
|
||||
site: 'https://gpt.qt.cool/',
|
||||
checkinUrl: 'https://gpt.qt.cool/checkin',
|
||||
usageUrl: 'https://gpt.qt.cool/user?key=',
|
||||
@@ -78,14 +78,23 @@ export const MODEL_PRESETS = {
|
||||
|
||||
/**
|
||||
* 动态获取 QTCOOL 模型列表
|
||||
* @param {string} [apiKey] - 自定义密钥,不传则用默认密钥
|
||||
* @param {string} [apiKey] - 自定义密钥;未传时尝试从已有配置读取
|
||||
* @returns {Promise<Array<{id:string, name:string, contextWindow:number, reasoning?:boolean}>>}
|
||||
*/
|
||||
export async function fetchQtcoolModels(apiKey) {
|
||||
const key = apiKey || QTCOOL.defaultKey
|
||||
let key = apiKey || QTCOOL.defaultKey
|
||||
// 没有 key 时尝试从已有的 qtcool provider 配置读取
|
||||
if (!key) {
|
||||
try {
|
||||
const { api } = await import('../lib/tauri-api.js')
|
||||
const cfg = await api.readOpenclawConfig()
|
||||
key = cfg?.models?.providers?.qtcool?.apiKey || ''
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const headers = key ? { 'Authorization': 'Bearer ' + key } : {}
|
||||
const resp = await fetch(QTCOOL.baseUrl + '/models', {
|
||||
headers: { 'Authorization': 'Bearer ' + key },
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000)
|
||||
})
|
||||
if (resp.ok) {
|
||||
|
||||
@@ -219,36 +219,36 @@ npm install -g openclaw@latest
|
||||
7. WhatsApp 不回消息 → 检查配对是否已 approve
|
||||
8. 认证错误 → 检查 auth-profiles.json 或重新 \`openclaw onboard\`
|
||||
|
||||
## 十二、ClawPanel 公益 AI 接口计划
|
||||
ClawPanel 项目组正式开放公益 AI 接口测试计划,降低用户接入 AI 能力的门槛。
|
||||
## 十二、晴辰云 AI 模型服务
|
||||
晴辰云为 ClawPanel 用户提供 AI 模型调用服务,通过每日签到和邀请好友即可获取免费额度。
|
||||
|
||||
### 核心信息
|
||||
- **GPT-AI 网关地址**:https://gpt.qt.cool/v1
|
||||
- **支持接口**:/v1/chat/completions、/v1/responses(OpenAI 兼容)
|
||||
- **支持模型**:OpenAI 全系列(GPT-5、GPT-5.1、GPT-5.2 及其 Codex 变体)
|
||||
- **费用**:测试期间所有调用成本由项目组内部承担,用户无需付费
|
||||
- **限制**:无请求限制
|
||||
- **用户后台**:https://gpt.qt.cool/user(查看用量、提交工单)
|
||||
- **免费额度**:每日签到领取免费额度,邀请好友可获得额外奖励
|
||||
- **高级模型**:低至官方价 2-3 折
|
||||
- **用户后台**:https://gpt.qt.cool/user(查看用量、管理密钥)
|
||||
|
||||
### 官方入口
|
||||
- **ClawPanel 官网**:https://claw.qt.cool/
|
||||
- **GPT-AI 网关官网**:https://gpt.qt.cool/
|
||||
- **每日签到页**:https://gpt.qt.cool/checkin
|
||||
- **用户后台**:https://gpt.qt.cool/user
|
||||
- **晴辰导航站**:https://qt.cool/
|
||||
|
||||
### 测试密钥
|
||||
- ClawPanel 已内置公共体验密钥,开箱即用
|
||||
- 用户也可前往 gpt.qt.cool 签到获取独立密钥
|
||||
- 独立密钥可在用户后台管理和查询用量
|
||||
### 获取密钥
|
||||
1. 前往 https://gpt.qt.cool/checkin 每日签到领取免费额度
|
||||
2. 邀请好友可获得额外奖励额度
|
||||
3. 在用户后台获取 API Key
|
||||
|
||||
### 接入方式
|
||||
已兼容 OpenAI API 的项目,只需替换:
|
||||
1. Base URL → https://gpt.qt.cool/v1
|
||||
2. API Key → 测试密钥
|
||||
2. API Key → 签到获取的密钥
|
||||
即可完成接入。
|
||||
|
||||
### 在 ClawPanel 中配置
|
||||
- **助手设置**:打开 AI 助手设置 → 模型配置 → 使用「一键接入」按钮
|
||||
- **模型配置页**:进入模型配置 → 使用「一键添加全部模型」按钮
|
||||
- 两处均自动填入网关地址和内置密钥
|
||||
- **助手设置**:打开 AI 助手设置 → 模型配置 → 输入密钥后点击「接入」
|
||||
- **模型配置页**:进入模型配置 → 输入密钥后点击「获取模型列表」添加模型
|
||||
`.trim()
|
||||
|
||||
@@ -213,15 +213,29 @@ export const api = {
|
||||
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }),
|
||||
|
||||
// 消息渠道管理
|
||||
readPlatformConfig: (platform) => invoke('read_platform_config', { platform }),
|
||||
saveMessagingPlatform: (platform, form, accountId) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form, accountId: accountId || null }) },
|
||||
removeMessagingPlatform: (platform) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('remove_messaging_platform', { platform }) },
|
||||
readPlatformConfig: (platform, accountId) => invoke('read_platform_config', { platform, accountId: accountId || null }),
|
||||
saveMessagingPlatform: (platform, form, accountId, agentId) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form, accountId: accountId || null, agentId: agentId || null }) },
|
||||
removeMessagingPlatform: (platform, accountId) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('remove_messaging_platform', { platform, accountId: accountId || null }) },
|
||||
toggleMessagingPlatform: (platform, enabled) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('toggle_messaging_platform', { platform, enabled }) },
|
||||
verifyBotToken: (platform, form) => invoke('verify_bot_token', { platform, form }),
|
||||
diagnoseChannel: (platform, accountId) => invoke('diagnose_channel', { platform, accountId: accountId || null }),
|
||||
repairQqbotChannelSetup: () => {
|
||||
invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config')
|
||||
return invoke('repair_qqbot_channel_setup')
|
||||
},
|
||||
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
|
||||
getChannelPluginStatus: (pluginId) => invoke('get_channel_plugin_status', { pluginId }),
|
||||
installQqbotPlugin: () => invoke('install_qqbot_plugin'),
|
||||
installChannelPlugin: (packageName, pluginId) => invoke('install_channel_plugin', { packageName, pluginId }),
|
||||
runChannelAction: (platform, action) => invoke('run_channel_action', { platform, action }),
|
||||
checkWeixinPluginStatus: () => invoke('check_weixin_plugin_status'),
|
||||
|
||||
// Agent 渠道绑定管理
|
||||
getAgentBindings: (agentId) => invoke('get_agent_bindings', { agentId }),
|
||||
listAllBindings: () => invoke('list_all_bindings'),
|
||||
saveAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('save_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || {} }) },
|
||||
deleteAgentBinding: (agentId, channel, accountId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null }) },
|
||||
deleteAgentAllBindings: (agentId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_all_bindings', { agentId }) },
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
getOpenclawDir: () => invoke('get_openclaw_dir'),
|
||||
|
||||
@@ -9,10 +9,26 @@ export function initTheme() {
|
||||
applyTheme(theme)
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const current = document.documentElement.dataset.theme || 'light'
|
||||
export function toggleTheme(onApply) {
|
||||
const html = document.documentElement
|
||||
const current = html.dataset.theme || 'light'
|
||||
const next = current === 'dark' ? 'light' : 'dark'
|
||||
applyTheme(next)
|
||||
|
||||
// 设置扩散起点:白切黑从左下角,黑切白从右上角
|
||||
const toDark = next === 'dark'
|
||||
html.style.setProperty('--theme-reveal-x', toDark ? '0%' : '100%')
|
||||
html.style.setProperty('--theme-reveal-y', toDark ? '100%' : '0%')
|
||||
|
||||
const doApply = () => {
|
||||
applyTheme(next)
|
||||
if (onApply) onApply(next)
|
||||
}
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(doApply)
|
||||
} else {
|
||||
doApply()
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ export function uuid() {
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT = 30000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const PING_INTERVAL = 25000
|
||||
const CHALLENGE_TIMEOUT = 5000
|
||||
const MAX_RECONNECT_DELAY = 60000
|
||||
const PING_INTERVAL = 30000
|
||||
const CHALLENGE_TIMEOUT = 15000
|
||||
const MAX_RECONNECT_ATTEMPTS = 20
|
||||
const HEARTBEAT_TIMEOUT = 90000
|
||||
const MESSAGE_CACHE_SIZE = 100
|
||||
// Gateway 启动前的初始重连延迟(更长,给 Gateway 充足的重启/初始化时间)
|
||||
const INITIAL_RECONNECT_DELAY = 10000
|
||||
|
||||
export class WsClient {
|
||||
constructor() {
|
||||
@@ -48,6 +53,19 @@ export class WsClient {
|
||||
this._wsId = 0
|
||||
this._autoPairAttempts = 0
|
||||
this._serverVersion = null
|
||||
|
||||
// 增强状态追踪
|
||||
this._lastConnectedAt = null
|
||||
this._lastMessageAt = null
|
||||
this._pendingReconnect = false
|
||||
this._missedHeartbeats = 0
|
||||
this._heartbeatTimer = null
|
||||
this._reconnectState = 'idle' // idle | attempting | scheduled
|
||||
|
||||
// 消息缓存
|
||||
this._messageCache = new Map()
|
||||
this._cacheSize = MESSAGE_CACHE_SIZE
|
||||
this._seenMessageIds = new Set()
|
||||
}
|
||||
|
||||
get connected() { return this._connected }
|
||||
@@ -57,6 +75,27 @@ export class WsClient {
|
||||
get hello() { return this._hello }
|
||||
get sessionKey() { return this._sessionKey }
|
||||
get serverVersion() { return this._serverVersion }
|
||||
get reconnectState() { return this._reconnectState }
|
||||
get reconnectAttempts() { return this._reconnectAttempts }
|
||||
get lastConnectedAt() { return this._lastConnectedAt }
|
||||
get lastMessageAt() { return this._lastMessageAt }
|
||||
|
||||
/**
|
||||
* 获取连接详细信息,供前端使用
|
||||
*/
|
||||
getConnectionInfo() {
|
||||
return {
|
||||
connected: this._connected,
|
||||
gatewayReady: this._gatewayReady,
|
||||
lastConnectedAt: this._lastConnectedAt,
|
||||
lastMessageAt: this._lastMessageAt,
|
||||
reconnectAttempts: this._reconnectAttempts,
|
||||
reconnectState: this._reconnectState,
|
||||
serverVersion: this._serverVersion,
|
||||
missedHeartbeats: this._missedHeartbeats,
|
||||
pendingReconnect: this._pendingReconnect,
|
||||
}
|
||||
}
|
||||
|
||||
onStatusChange(fn) {
|
||||
this._statusListeners.push(fn)
|
||||
@@ -80,12 +119,14 @@ export class WsClient {
|
||||
}
|
||||
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
|
||||
this._url = nextUrl
|
||||
this._lastConnectedAt = Date.now()
|
||||
this._doConnect()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._intentionalClose = true
|
||||
this._stopPing()
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnectTimer()
|
||||
this._clearChallengeTimer()
|
||||
this._flushPending()
|
||||
@@ -93,6 +134,8 @@ export class WsClient {
|
||||
this._setConnected(false)
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
@@ -100,7 +143,9 @@ export class WsClient {
|
||||
this._intentionalClose = false
|
||||
this._reconnectAttempts = 0
|
||||
this._autoPairAttempts = 0
|
||||
this._missedHeartbeats = 0
|
||||
this._stopPing()
|
||||
this._stopHeartbeat()
|
||||
this._clearReconnectTimer()
|
||||
this._clearChallengeTimer()
|
||||
this._flushPending()
|
||||
@@ -113,6 +158,7 @@ export class WsClient {
|
||||
this._closeWs()
|
||||
this._gatewayReady = false
|
||||
this._handshaking = false
|
||||
this._reconnectState = 'attempting'
|
||||
this._setConnected(false, 'connecting')
|
||||
const wsId = ++this._wsId
|
||||
let ws
|
||||
@@ -123,6 +169,10 @@ export class WsClient {
|
||||
if (wsId !== this._wsId) return
|
||||
this._connecting = false
|
||||
this._reconnectAttempts = 0
|
||||
this._missedHeartbeats = 0
|
||||
this._lastConnectedAt = Date.now()
|
||||
this._lastMessageAt = Date.now()
|
||||
this._startHeartbeat()
|
||||
this._setConnected(true)
|
||||
this._startPing()
|
||||
// 等 Gateway 发 connect.challenge,超时则主动发
|
||||
@@ -171,10 +221,16 @@ export class WsClient {
|
||||
if (!this._intentionalClose) this._scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.onerror = () => {}
|
||||
ws.onerror = (err) => {
|
||||
console.error('[ws] WebSocket 错误:', err)
|
||||
}
|
||||
}
|
||||
|
||||
_handleMessage(msg) {
|
||||
// 更新最后消息时间(用于心跳检测)
|
||||
this._lastMessageAt = Date.now()
|
||||
this._missedHeartbeats = 0
|
||||
|
||||
// 握手阶段:connect.challenge
|
||||
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
console.log('[ws] 收到 connect.challenge')
|
||||
@@ -228,6 +284,25 @@ export class WsClient {
|
||||
|
||||
// 事件转发
|
||||
if (msg.type === 'event') {
|
||||
// 消息去重检查
|
||||
if (msg.id && this._seenMessageIds.has(msg.id)) {
|
||||
console.log('[ws] 跳过重复消息:', msg.id)
|
||||
return
|
||||
}
|
||||
if (msg.id) {
|
||||
this._seenMessageIds.add(msg.id)
|
||||
// 保持 Set 大小,防止内存泄漏
|
||||
if (this._seenMessageIds.size > 1000) {
|
||||
const arr = Array.from(this._seenMessageIds)
|
||||
this._seenMessageIds = new Set(arr.slice(-500))
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存聊天消息
|
||||
if (msg.event === 'chat.message' && msg.payload?.sessionKey) {
|
||||
this._cacheMessage(msg.payload.sessionKey, msg.payload)
|
||||
}
|
||||
|
||||
this._eventListeners.forEach(fn => {
|
||||
try { fn(msg) } catch (e) { console.error('[ws] handler error:', e) }
|
||||
})
|
||||
@@ -288,6 +363,8 @@ export class WsClient {
|
||||
this._sessionKey = `agent:${agentId}:main`
|
||||
}
|
||||
this._gatewayReady = true
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
console.log('[ws] Gateway 就绪, sessionKey:', this._sessionKey)
|
||||
this._setConnected(true, 'ready')
|
||||
this._readyCallbacks.forEach(fn => {
|
||||
@@ -337,13 +414,36 @@ export class WsClient {
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// 超过最大重连次数,停止重连
|
||||
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn('[ws] 已达到最大重连次数 (', MAX_RECONNECT_ATTEMPTS, '),停止自动重连')
|
||||
this._reconnectState = 'idle'
|
||||
this._pendingReconnect = false
|
||||
this._setConnected(false, 'error', `连接失败,已停止重连。请手动刷新页面重试。`)
|
||||
return
|
||||
}
|
||||
|
||||
this._clearReconnectTimer()
|
||||
const delay = this._reconnectAttempts < 3
|
||||
? 1000
|
||||
: Math.min(1000 * Math.pow(2, this._reconnectAttempts - 2), MAX_RECONNECT_DELAY)
|
||||
// 指数退避:1s, 2s, 4s, 8s, 16s, 32s, 60s (最多 60s)
|
||||
const baseDelay = 2000
|
||||
const maxDelay = MAX_RECONNECT_DELAY
|
||||
const exponentialDelay = Math.min(baseDelay * Math.pow(2, this._reconnectAttempts), maxDelay)
|
||||
// 首次连接(Gateway 可能还未启动):使用更长的初始延迟
|
||||
const delay = this._reconnectAttempts === 0
|
||||
? INITIAL_RECONNECT_DELAY
|
||||
: Math.round(exponentialDelay * (0.5 + Math.random())) // 50%~150% 抖动,防止同步风暴
|
||||
|
||||
this._reconnectAttempts++
|
||||
this._setConnected(false, 'reconnecting')
|
||||
this._reconnectTimer = setTimeout(() => this._doConnect(), delay)
|
||||
this._reconnectState = 'scheduled'
|
||||
this._pendingReconnect = true
|
||||
this._setConnected(false, 'reconnecting', `重连中 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),${Math.round(delay/1000)}秒后...`)
|
||||
console.log(`[ws] 计划重连 (${this._reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}),延迟 ${Math.round(delay/1000)}秒`)
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
if (!this._intentionalClose) {
|
||||
this._reconnectState = 'attempting'
|
||||
this._doConnect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
_startPing() {
|
||||
@@ -362,6 +462,45 @@ export class WsClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳检测:如果超过 HEARTBEAT_TIMEOUT 没有收到任何消息,触发重连
|
||||
* 这用于检测 Gateway 端崩溃或网络中断
|
||||
*/
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat()
|
||||
this._missedHeartbeats = 0
|
||||
this._heartbeatTimer = setInterval(() => {
|
||||
if (!this._connected || !this._gatewayReady) return
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastMessage = this._lastMessageAt ? now - this._lastMessageAt : 0
|
||||
|
||||
if (timeSinceLastMessage > HEARTBEAT_TIMEOUT) {
|
||||
this._missedHeartbeats++
|
||||
console.warn(`[ws] 心跳超时 (${Math.round(timeSinceLastMessage/1000)}秒),missedHeartbeats: ${this._missedHeartbeats}`)
|
||||
// 增加容忍度:连续 3 次超时(检查间隔 30s × 3 = 约 90s)才强制重连
|
||||
if (this._missedHeartbeats >= 3) {
|
||||
console.error('[ws] 心跳检测失败超过3次,强制重连')
|
||||
this._stopHeartbeat()
|
||||
this.reconnect()
|
||||
} else if (this._missedHeartbeats >= 2) {
|
||||
// 2 次超时:先尝试发 ping 探测,不行再重连
|
||||
console.warn('[ws] 心跳超时 2 次,发送探测 ping...')
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.send('{"type":"ping"}') } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT / 3) // 每 30 秒检查一次
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatTimer) {
|
||||
clearInterval(this._heartbeatTimer)
|
||||
this._heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
request(method, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
|
||||
@@ -419,6 +558,104 @@ export class WsClient {
|
||||
this._eventListeners.push(callback)
|
||||
return () => { this._eventListeners = this._eventListeners.filter(fn => fn !== callback) }
|
||||
}
|
||||
|
||||
// ==================== 消息缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 缓存消息
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @param {object} message - 消息对象
|
||||
*/
|
||||
_cacheMessage(sessionKey, message) {
|
||||
if (!this._messageCache.has(sessionKey)) {
|
||||
this._messageCache.set(sessionKey, [])
|
||||
}
|
||||
const messages = this._messageCache.get(sessionKey)
|
||||
|
||||
// 去重检查(基于消息 ID 或内容哈希)
|
||||
const msgId = message.id || message.messageId
|
||||
if (msgId && messages.some(m => (m.id || m.messageId) === msgId)) {
|
||||
return
|
||||
}
|
||||
|
||||
messages.push({
|
||||
...message,
|
||||
_cachedAt: Date.now(),
|
||||
})
|
||||
|
||||
// 限制缓存大小
|
||||
if (messages.length > this._cacheSize) {
|
||||
messages.splice(0, messages.length - this._cacheSize)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的消息
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @returns {array} 缓存的消息数组
|
||||
*/
|
||||
_getCachedMessages(sessionKey) {
|
||||
return this._messageCache.get(sessionKey) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定会话的缓存
|
||||
* @param {string} sessionKey - 会话 key
|
||||
*/
|
||||
_clearCache(sessionKey) {
|
||||
if (sessionKey) {
|
||||
this._messageCache.delete(sessionKey)
|
||||
} else {
|
||||
this._messageCache.clear()
|
||||
}
|
||||
console.log('[ws] 消息缓存已清除:', sessionKey || '全部')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除消息去重记录
|
||||
*/
|
||||
_clearSeenMessageIds() {
|
||||
this._seenMessageIds.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息
|
||||
*/
|
||||
getCacheInfo() {
|
||||
const info = {}
|
||||
for (const [key, messages] of this._messageCache) {
|
||||
info[key] = {
|
||||
count: messages.length,
|
||||
oldest: messages[0]?._cachedAt,
|
||||
newest: messages[messages.length - 1]?._cachedAt,
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接成功后自动拉取历史消息(供前端调用)
|
||||
* @param {string} sessionKey - 会话 key
|
||||
* @param {number} limit - 消息数量限制
|
||||
*/
|
||||
async fetchHistoryOnReconnect(sessionKey, limit = 200) {
|
||||
if (!sessionKey || !this._gatewayReady) {
|
||||
return { error: 'not ready' }
|
||||
}
|
||||
try {
|
||||
const history = await this.chatHistory(sessionKey, limit)
|
||||
// 将历史消息缓存起来
|
||||
if (history?.messages) {
|
||||
for (const msg of history.messages) {
|
||||
this._cacheMessage(sessionKey, msg)
|
||||
}
|
||||
}
|
||||
return { history }
|
||||
} catch (e) {
|
||||
console.error('[ws] 拉取历史消息失败:', e)
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _g = typeof window !== 'undefined' ? window : globalThis
|
||||
|
||||
37
src/main.js
37
src/main.js
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* ClawPanel 入口
|
||||
*/
|
||||
|
||||
// 模块已加载,取消 splash 超时回退(防止假阳性的 "页面加载失败" 提示)
|
||||
if (window._splashTimer) { clearTimeout(window._splashTimer); window._splashTimer = null }
|
||||
|
||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
|
||||
import { initTheme } from './lib/theme.js'
|
||||
@@ -26,6 +30,11 @@ import './style/ai-drawer.css'
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
|
||||
/** HTML 转义,防止 XSS 注入 */
|
||||
function escapeHtml(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// === 访问密码保护(Web + 桌面端通用) ===
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
@@ -511,10 +520,10 @@ function setupGatewayBanner() {
|
||||
banner.classList.remove('gw-banner-hidden')
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
|
||||
<span>Gateway 未运行</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">启动</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">启动</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
|
||||
<button class="gw-banner-close" id="btn-gw-dismiss" title="关闭提示">×</button>
|
||||
</div>
|
||||
`
|
||||
@@ -533,13 +542,13 @@ function setupGatewayBanner() {
|
||||
const errMsg = (err.message || String(err)).slice(0, 120)
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content" style="flex-wrap:wrap">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
|
||||
<span>启动失败</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;font-size:12px">查看日志</a>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${errMsg}</div>
|
||||
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${escapeHtml(errMsg)}</div>
|
||||
`
|
||||
update(false)
|
||||
return
|
||||
@@ -564,10 +573,10 @@ function setupGatewayBanner() {
|
||||
} catch {}
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span class="gw-banner-icon">${statusIcon('info', 16)}</span>
|
||||
<span>启动超时,Gateway 可能仍在启动中</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-start" style="margin-left:auto">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
|
||||
</div>
|
||||
${logHint}
|
||||
`
|
||||
@@ -588,10 +597,10 @@ function showGuardianRecovery() {
|
||||
<div class="gw-banner-content" style="flex-wrap:wrap;gap:8px">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span>Gateway 反复启动失败,可能配置有误</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-recover-restart">重试启动</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-recover-restart" style="margin-left:auto">重试启动</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-gw-recover-backup">从备份恢复</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;text-decoration:underline">服务管理</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services">服务管理</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs">查看日志</a>
|
||||
</div>
|
||||
`
|
||||
banner.querySelector('#btn-gw-recover-restart')?.addEventListener('click', async (e) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showModal, showConfirm } from '../components/modal.js'
|
||||
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -25,7 +26,7 @@ export async function render() {
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { agents: [] }
|
||||
const state = { agents: [], bindings: [] }
|
||||
// 非阻塞:先返回 DOM,后台加载数据
|
||||
loadAgents(page, state)
|
||||
|
||||
@@ -52,7 +53,12 @@ async function loadAgents(page, state) {
|
||||
const container = page.querySelector('#agents-list')
|
||||
renderSkeleton(container)
|
||||
try {
|
||||
state.agents = await api.listAgents()
|
||||
const [agents, config] = await Promise.all([
|
||||
api.listAgents(),
|
||||
api.readOpenclawConfig().catch(() => null),
|
||||
])
|
||||
state.agents = agents
|
||||
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
|
||||
renderAgents(page, state)
|
||||
|
||||
// 只在第一次加载时绑定事件(避免重复绑定)
|
||||
@@ -61,11 +67,27 @@ async function loadAgents(page, state) {
|
||||
state.eventsAttached = true
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + e + '</div>'
|
||||
container.innerHTML = '<div style="color:var(--error);padding:20px">加载失败: ' + String(e).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</div>'
|
||||
toast('加载 Agent 列表失败: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/** 为指定 agent 生成绑定渠道的 badge HTML */
|
||||
function renderBindingBadges(agentId, bindings) {
|
||||
const matched = (bindings || []).filter(b => (b.agentId || 'main') === agentId)
|
||||
if (!matched.length) {
|
||||
return '<span style="color:var(--text-tertiary)">未绑定渠道</span>'
|
||||
}
|
||||
return matched.map(b => {
|
||||
const channel = b.match?.channel || ''
|
||||
const label = CHANNEL_LABELS[channel] || channel
|
||||
const accountId = b.match?.accountId
|
||||
const text = accountId ? `${label} · ${accountId}` : label
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
return `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px;white-space:nowrap">${escaped}</span>`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function renderAgents(page, state) {
|
||||
const container = page.querySelector('#agents-list')
|
||||
if (!state.agents.length) {
|
||||
@@ -102,6 +124,10 @@ function renderAgents(page, state) {
|
||||
<span class="agent-info-label">工作区:</span>
|
||||
<span class="agent-info-value" style="font-family:var(--font-mono);font-size:var(--font-size-xs)">${a.workspace || '未设置'}</span>
|
||||
</div>
|
||||
<div class="agent-info-row">
|
||||
<span class="agent-info-label">绑定渠道:</span>
|
||||
<span class="agent-info-value">${renderBindingBadges(a.id, state.bindings)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -149,7 +149,7 @@ ${personality}
|
||||
- openclaw skills check — 检查所有 Skills 的依赖是否满足
|
||||
- Skill 依赖安装: 根据 install spec 执行 brew/npm/go/uv 安装缺少的命令行工具
|
||||
- ClawHub (clawhub.com): 社区 Skill 市场,可搜索和安装新 Skill
|
||||
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 放在 ~/.openclaw/skills/<name>/
|
||||
- Skills 目录: 捆绑 Skills 在 openclaw 安装包内,自定义 Skills 通常位于 ~/.openclaw/skills/<name>/ 或 ~/.claude/skills/<name>/
|
||||
|
||||
### 聊天与调试
|
||||
- openclaw chat — 进入交互式聊天
|
||||
@@ -442,7 +442,7 @@ const TOOL_DEFS = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skills_clawhub_install',
|
||||
description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。',
|
||||
description: '从 ClawHub 社区市场安装一个 Skill 到本地自定义 Skills 目录(通常为 ~/.openclaw/skills/ 或 ~/.claude/skills/)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2468,8 +2468,18 @@ function renderMessages() {
|
||||
})
|
||||
}
|
||||
|
||||
function _linkify(str) { return str.replace(/(https?:\/\/[^\s,,。;))'"]+)/g, '<a href="$1" target="_blank" style="color:var(--primary)">$1</a>') }
|
||||
|
||||
function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatus, respBody, reply, error }) {
|
||||
let html = ''
|
||||
// 尝试解析 API 返回的错误信息
|
||||
let apiErrMsg = ''
|
||||
if (!success && respBody) {
|
||||
try {
|
||||
const errJson = JSON.parse(respBody)
|
||||
apiErrMsg = errJson.error?.message || errJson.message || ''
|
||||
} catch {}
|
||||
}
|
||||
// 状态行
|
||||
if (error) {
|
||||
html += `<span style="color:var(--error)">✗ 请求失败: ${escHtml(error)}</span>`
|
||||
@@ -2478,6 +2488,10 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
|
||||
} else {
|
||||
html += `<span style="color:var(--warning)">${statusIcon('warn', 14)} HTTP ${respStatus} — 请求完成但未解析到回复内容</span>`
|
||||
}
|
||||
// API 错误信息(完整展示,URL 可点击)
|
||||
if (apiErrMsg) {
|
||||
html += `<div style="margin-top:6px;padding:8px 10px;background:var(--bg-tertiary);border-left:3px solid var(--warning);border-radius:4px;font-size:12px;color:var(--text-secondary);line-height:1.6;word-break:break-all">${_linkify(escHtml(apiErrMsg))}</div>`
|
||||
}
|
||||
// 回复预览
|
||||
if (reply) {
|
||||
const short = reply.length > 80 ? reply.slice(0, 80) + '...' : reply
|
||||
@@ -2562,18 +2576,29 @@ function showSettings() {
|
||||
</div>
|
||||
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${apiHintText(c.apiType)}</div>
|
||||
|
||||
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:var(--radius-lg);background:var(--bg-tertiary);border:1px solid var(--border-primary);overflow:hidden">
|
||||
<div style="padding:14px 16px 10px">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||||
${icon('zap', 16)}
|
||||
<span style="font-weight:600;font-size:var(--font-size-sm)">晴辰云快捷接入</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 6px;border-radius:8px">推荐</span>
|
||||
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:var(--radius-lg);border:1px solid var(--border-primary);border-left:3px solid var(--primary);background:var(--bg-secondary);overflow:hidden">
|
||||
<div style="padding:14px 16px 12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px">
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
|
||||
<span style="font-weight:700;font-size:var(--font-size-sm)">${icon('zap', 14)} 晴辰云</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">推荐</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary);line-height:1.4">
|
||||
GPT-5 / Codex 全系列,低至官方价 2-3 折,不满意随时可退
|
||||
</div>
|
||||
</div>
|
||||
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-xs" style="flex-shrink:0">${icon('gift', 11)} 签到领额度</a>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5;margin-bottom:10px">
|
||||
无需自行申请 API Key,选择模型即可一键接入。基础模型免费体验,高级模型低至官方价 2-3 折。
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:8px">
|
||||
填入 API Key 后选择模型即可接入。没有密钥?<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">每日签到</a> 领取,在 <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">用户后台</a> 复制
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||||
<input class="form-input" id="ast-qtcool-key" placeholder="粘贴 API Key" style="font-size:12px;padding:5px 10px;flex:1;min-width:120px">
|
||||
<input type="checkbox" id="ast-qtcool-customkey" style="display:none">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<select id="ast-qtcool-model" class="form-input" style="font-size:12px;padding:5px 10px;min-width:140px;flex:1">
|
||||
<select id="ast-qtcool-model" class="form-input" style="font-size:12px;padding:5px 10px;min-width:130px;flex:1">
|
||||
<option value="" disabled selected>加载模型列表...</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-secondary" id="ast-qtcool-test">${icon('search', 12)} 测试</button>
|
||||
@@ -2581,16 +2606,12 @@ function showSettings() {
|
||||
</div>
|
||||
<div id="ast-qtcool-status" style="margin-top:8px;font-size:11px;min-height:16px;line-height:1.5"></div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border-primary);padding:8px 16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:var(--bg-secondary)">
|
||||
<label style="cursor:pointer;display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-tertiary)">
|
||||
<input type="checkbox" id="ast-qtcool-customkey" style="accent-color:var(--primary);width:13px;height:13px"> 使用自定义密钥
|
||||
</label>
|
||||
<div style="display:flex;gap:12px;font-size:11px">
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">${icon('external-link', 12)} 了解更多</a>
|
||||
<div style="border-top:1px solid var(--border-primary);padding:6px 16px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px;background:var(--bg-tertiary)">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-to" title="将助手配置同步到 OpenClaw 全局">${icon('upload', 11)} 同步到 OpenClaw</button>
|
||||
<button class="btn btn-xs btn-secondary" id="ast-qtcool-sync-from" title="从 OpenClaw 全局配置读取">${icon('download', 11)} 从 OpenClaw 读取</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ast-qtcool-keyrow" style="display:none;border-top:1px solid var(--border-primary);padding:8px 16px;background:var(--bg-tertiary)">
|
||||
<input class="form-input" id="ast-qtcool-key" placeholder="粘贴你的密钥" style="font-size:12px;padding:6px 10px">
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none;font-size:11px">${icon('external-link', 11)} 了解更多</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2934,9 +2955,7 @@ function showSettings() {
|
||||
// ── gpt.qt.cool 一键配置 ──
|
||||
const qtcoolModelSelect = overlay.querySelector('#ast-qtcool-model')
|
||||
const qtcoolCustomKeyCheckbox = overlay.querySelector('#ast-qtcool-customkey')
|
||||
const qtcoolKeyRow = overlay.querySelector('#ast-qtcool-keyrow')
|
||||
const qtcoolKeyInput = overlay.querySelector('#ast-qtcool-key')
|
||||
const qtcoolUsageLink = overlay.querySelector('#ast-qtcool-usage')
|
||||
|
||||
// 动态获取模型列表(共享逻辑)
|
||||
;(async () => {
|
||||
@@ -2946,14 +2965,7 @@ function showSettings() {
|
||||
).join('')
|
||||
})()
|
||||
|
||||
qtcoolCustomKeyCheckbox.onchange = () => {
|
||||
qtcoolKeyRow.style.display = qtcoolCustomKeyCheckbox.checked ? '' : 'none'
|
||||
if (qtcoolCustomKeyCheckbox.checked) qtcoolKeyInput.focus()
|
||||
}
|
||||
qtcoolKeyInput.oninput = () => {
|
||||
const key = qtcoolKeyInput.value.trim()
|
||||
qtcoolUsageLink.href = QTCOOL.usageUrl + (key || QTCOOL.defaultKey)
|
||||
}
|
||||
// key input is always visible now (no more built-in key)
|
||||
const qtcoolStatus = overlay.querySelector('#ast-qtcool-status')
|
||||
|
||||
// 测试按钮:快速验证接口可用性
|
||||
@@ -2961,8 +2973,8 @@ function showSettings() {
|
||||
const btn = e.target
|
||||
const selectedModel = qtcoolModelSelect.value
|
||||
if (!selectedModel) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先选择模型</span>`; return }
|
||||
const customKey = qtcoolCustomKeyCheckbox.checked ? qtcoolKeyInput.value.trim() : ''
|
||||
const key = customKey || QTCOOL.defaultKey
|
||||
const key = qtcoolKeyInput.value.trim()
|
||||
if (!key) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先输入 API Key(<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到领取</a>)</span>`; return }
|
||||
|
||||
btn.disabled = true
|
||||
btn.textContent = '测试中...'
|
||||
@@ -2982,10 +2994,17 @@ function showSettings() {
|
||||
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} 测试通过(${(ms/1000).toFixed(1)}s)</span><span style="color:rgba(255,255,255,0.4);margin-left:6px">${selectedModel} 响应正常</span>`
|
||||
} else {
|
||||
const errText = await resp.text().catch(() => '')
|
||||
qtcoolStatus.innerHTML = `<span style="color:#f87171">${statusIcon('err', 14)} 测试失败(HTTP ${resp.status})</span><span style="color:rgba(255,255,255,0.4);margin-left:6px">${errText.slice(0, 80)}</span>`
|
||||
let errMsg = `HTTP ${resp.status}`
|
||||
try {
|
||||
const errJson = JSON.parse(errText)
|
||||
if (errJson.error?.message) errMsg = errJson.error.message
|
||||
} catch { if (errText) errMsg += ' — ' + errText.slice(0, 200) }
|
||||
// 将 URL 转为可点击链接
|
||||
const errHtml = errMsg.replace(/(https?:\/\/[^\s,,。))]+)/g, '<a href="$1" target="_blank" style="color:var(--primary)">$1</a>')
|
||||
qtcoolStatus.innerHTML = `<div style="color:#f87171;line-height:1.5">${statusIcon('err', 14)} <strong>测试失败</strong></div><div style="color:var(--text-secondary);font-size:11px;line-height:1.5;margin-top:4px;word-break:break-all">${errHtml}</div>`
|
||||
}
|
||||
} catch (err) {
|
||||
qtcoolStatus.innerHTML = `<span style="color:#f87171">${statusIcon('err', 14)} 连接失败:${err.message}</span>`
|
||||
qtcoolStatus.innerHTML = `<div style="color:#f87171">${statusIcon('err', 14)} 连接失败:${err.message}</div>`
|
||||
}
|
||||
btn.disabled = false
|
||||
btn.innerHTML = `${icon('search', 12)} 测试`
|
||||
@@ -2995,8 +3014,8 @@ function showSettings() {
|
||||
overlay.querySelector('#ast-qtcool-apply').onclick = async () => {
|
||||
const selectedModel = qtcoolModelSelect.value
|
||||
if (!selectedModel) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先选择模型</span>`; return }
|
||||
const customKey = qtcoolCustomKeyCheckbox.checked ? qtcoolKeyInput.value.trim() : ''
|
||||
const key = customKey || QTCOOL.defaultKey
|
||||
const key = qtcoolKeyInput.value.trim()
|
||||
if (!key) { qtcoolStatus.innerHTML = `<span style="color:#fbbf24">${statusIcon('warn', 14)} 请先输入 API Key(<a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到领取</a>)</span>`; return }
|
||||
|
||||
// 1) 填充助手配置
|
||||
overlay.querySelector('#ast-baseurl').value = QTCOOL.baseUrl
|
||||
@@ -3052,6 +3071,76 @@ function showSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到 OpenClaw:将助手的 baseUrl/apiKey/model 写入 openclaw.json
|
||||
overlay.querySelector('#ast-qtcool-sync-to')?.addEventListener('click', async () => {
|
||||
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
|
||||
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
|
||||
const model = overlay.querySelector('#ast-model').value.trim()
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
toast('请先在上方配置好 Base URL、API Key 和模型', 'warning')
|
||||
return
|
||||
}
|
||||
const yes = await showConfirm(
|
||||
'同步到 OpenClaw',
|
||||
`将当前助手的模型配置写入 OpenClaw 全局:\n\n• 服务商:晴辰云(qtcool)\n• 模型:${model}\n• 设为全局主模型\n\n此操作会覆盖已有的晴辰云服务商配置并重启 Gateway。`,
|
||||
{ confirmText: '确认同步', cancelText: '取消' }
|
||||
)
|
||||
if (!yes) return
|
||||
try {
|
||||
let config = {}
|
||||
try { config = await api.readOpenclawConfig() } catch {}
|
||||
if (!config.models) config.models = {}
|
||||
if (!config.models.providers) config.models.providers = {}
|
||||
config.models.providers.qtcool = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api: 'openai-completions',
|
||||
models: [{ id: model, name: model, contextWindow: 128000, reasoning: model.includes('codex') }]
|
||||
}
|
||||
if (!config.agents) config.agents = {}
|
||||
if (!config.agents.defaults) config.agents.defaults = {}
|
||||
if (!config.agents.defaults.model) config.agents.defaults.model = {}
|
||||
config.agents.defaults.model.primary = 'qtcool/' + model
|
||||
await api.writeOpenclawConfig(config)
|
||||
toast('已同步到 OpenClaw 全局配置,主模型: qtcool/' + model, 'success')
|
||||
try { await api.restartGateway() } catch {}
|
||||
} catch (e) {
|
||||
toast('同步失败: ' + e, 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// 从 OpenClaw 读取:将 openclaw.json 的 qtcool provider 配置填入助手
|
||||
overlay.querySelector('#ast-qtcool-sync-from')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
const qtProvider = config?.models?.providers?.qtcool
|
||||
if (!qtProvider?.baseUrl) {
|
||||
toast('OpenClaw 中尚未配置晴辰云服务商,请先在模型配置页添加', 'info')
|
||||
return
|
||||
}
|
||||
const primary = config?.agents?.defaults?.model?.primary || ''
|
||||
const primaryModel = primary.startsWith('qtcool/') ? primary.slice(7) : ''
|
||||
const firstModel = (qtProvider.models || [])[0]
|
||||
const modelId = primaryModel || (typeof firstModel === 'string' ? firstModel : firstModel?.id) || ''
|
||||
const yes = await showConfirm(
|
||||
'从 OpenClaw 读取',
|
||||
`将 OpenClaw 全局配置填入助手:\n\n• Base URL:${qtProvider.baseUrl}\n• API Key:${qtProvider.apiKey ? '****' + qtProvider.apiKey.slice(-6) : '(空)'}\n${modelId ? '• 模型:' + modelId : ''}\n\n这会覆盖当前助手的模型配置。`,
|
||||
{ confirmText: '确认读取', cancelText: '取消' }
|
||||
)
|
||||
if (!yes) return
|
||||
overlay.querySelector('#ast-baseurl').value = qtProvider.baseUrl
|
||||
if (qtProvider.apiKey) {
|
||||
overlay.querySelector('#ast-apikey').value = qtProvider.apiKey
|
||||
qtcoolKeyInput.value = qtProvider.apiKey
|
||||
}
|
||||
overlay.querySelector('#ast-apitype').value = qtProvider.api || 'openai-completions'
|
||||
if (modelId) overlay.querySelector('#ast-model').value = modelId
|
||||
toast('已从 OpenClaw 读取配置', 'success')
|
||||
} catch (e) {
|
||||
toast('读取 OpenClaw 配置失败: ' + e, 'error')
|
||||
}
|
||||
})
|
||||
|
||||
const resultEl = overlay.querySelector('#ast-test-result')
|
||||
const modelInput = overlay.querySelector('#ast-model')
|
||||
const dropdown = overlay.querySelector('#ast-model-dropdown')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,20 +25,26 @@ export async function render() {
|
||||
服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。
|
||||
标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
|
||||
</div>
|
||||
<div id="qtcool-promo" style="margin-bottom:var(--space-md);border-radius:var(--radius-lg);background:var(--bg-secondary);border:1px solid var(--border-primary);padding:14px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||||
${icon('zap', 16)}
|
||||
<span style="font-weight:600;font-size:var(--font-size-sm)">晴辰云</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 6px;border-radius:8px">推荐</span>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
|
||||
无需自行注册 API,一键添加即可使用。基础模型免费,高级模型低至官方价 2-3 折
|
||||
<div id="qtcool-promo" style="margin-bottom:var(--space-md);border-radius:var(--radius-lg);border:1px solid var(--border-primary);border-left:3px solid var(--primary);background:var(--bg-secondary);padding:16px 20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;margin-bottom:12px">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:700;font-size:var(--font-size-base);color:var(--text-primary)">${icon('zap', 15)} 晴辰云</span>
|
||||
<span style="font-size:10px;background:var(--primary);color:#fff;padding:1px 7px;border-radius:8px">推荐</span>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
|
||||
GPT-5 / Codex 全系列,低至官方价 2-3 折,不满意随时可退。
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none">了解更多 →</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${QTCOOL.checkinUrl}" target="_blank" class="btn btn-primary btn-sm">${icon('gift', 12)} 每日签到领额度</a>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input class="form-input" id="qtcool-apikey" placeholder="粘贴 API Key(签到后在用户后台获取)" style="font-size:12px;padding:6px 10px;flex:1;min-width:180px">
|
||||
<button class="btn btn-primary btn-sm" id="btn-qtcool-oneclick">${icon('plus', 14)} 获取模型列表</button>
|
||||
<a href="${QTCOOL.site}" target="_blank" class="btn btn-secondary btn-sm">${icon('external-link', 12)} 了解更多</a>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-tertiary);margin-top:6px">
|
||||
没有密钥?前往 <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary)">签到页</a> 每日签到即可领取免费额度,在 <a href="${QTCOOL.usageUrl}" target="_blank" style="color:var(--primary)">用户后台</a> 复制你的 Key
|
||||
</div>
|
||||
</div>
|
||||
<div id="default-model-bar"></div>
|
||||
@@ -755,11 +761,14 @@ function bindTopActions(page, state) {
|
||||
page.querySelector('#btn-qtcool-oneclick').onclick = async () => {
|
||||
if (!state.config) { toast('配置未加载完成,请稍候', 'warning'); return }
|
||||
|
||||
const bannerKeyInput = page.querySelector('#qtcool-apikey')
|
||||
const bannerKey = bannerKeyInput ? bannerKeyInput.value.trim() : ''
|
||||
|
||||
const btn = page.querySelector('#btn-qtcool-oneclick')
|
||||
btn.textContent = '获取中...'
|
||||
btn.disabled = true
|
||||
|
||||
const models = await fetchQtcoolModels()
|
||||
const models = await fetchQtcoolModels(bannerKey || undefined)
|
||||
|
||||
btn.innerHTML = `${icon('plus', 14)} 获取模型列表`
|
||||
btn.disabled = false
|
||||
@@ -780,6 +789,10 @@ function bindTopActions(page, state) {
|
||||
<div class="modal" style="max-height:80vh;overflow-y:auto">
|
||||
<div class="modal-title">选择要添加的模型</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">从晴辰云获取到 ${models.length} 个可用模型,勾选需要的模型后点击添加。</div>
|
||||
${!existingProvider ? `<div style="margin-bottom:12px">
|
||||
<label class="form-label" style="font-size:var(--font-size-xs)">API Key <a href="${QTCOOL.checkinUrl}" target="_blank" style="color:var(--primary);font-weight:400">每日签到领免费额度 →</a></label>
|
||||
<input class="form-input" id="qtsel-apikey" placeholder="粘贴你的 API Key" style="font-size:12px">
|
||||
</div>` : ''}
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-all">全选</button>
|
||||
<button class="btn btn-sm btn-secondary" id="qtsel-none">全不选</button>
|
||||
@@ -801,6 +814,9 @@ function bindTopActions(page, state) {
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
// 从横幅预填充 key
|
||||
const dialogKeyInput = overlay.querySelector('#qtsel-apikey')
|
||||
if (dialogKeyInput && bannerKey) dialogKeyInput.value = bannerKey
|
||||
overlay.querySelector('#qtsel-cancel').onclick = () => overlay.remove()
|
||||
overlay.querySelector('#qtsel-all').onclick = () => {
|
||||
overlay.querySelectorAll('#qtmodel-list input:not(:disabled)').forEach(cb => cb.checked = true)
|
||||
@@ -810,9 +826,18 @@ function bindTopActions(page, state) {
|
||||
}
|
||||
overlay.querySelector('#qtsel-confirm').onclick = () => {
|
||||
const selected = [...overlay.querySelectorAll('#qtmodel-list input:checked:not(:disabled)')].map(cb => cb.value)
|
||||
overlay.remove()
|
||||
if (!selected.length) { toast('未选择任何模型', 'info'); return }
|
||||
|
||||
// 新建服务商时需要 API Key
|
||||
const keyInput = overlay.querySelector('#qtsel-apikey')
|
||||
const apiKey = keyInput ? keyInput.value.trim() : ''
|
||||
if (!existingProvider && !apiKey) {
|
||||
toast('请输入 API Key(可通过每日签到免费获取)', 'warning')
|
||||
keyInput?.focus()
|
||||
return
|
||||
}
|
||||
overlay.remove()
|
||||
|
||||
pushUndo(state)
|
||||
if (!state.config.models) state.config.models = {}
|
||||
if (!state.config.models.providers) state.config.models.providers = {}
|
||||
@@ -827,7 +852,7 @@ function bindTopActions(page, state) {
|
||||
} else {
|
||||
state.config.models.providers[QTCOOL.providerKey] = {
|
||||
baseUrl: QTCOOL.baseUrl,
|
||||
apiKey: QTCOOL.defaultKey,
|
||||
apiKey: apiKey,
|
||||
api: QTCOOL.api,
|
||||
models: selectedModels.map(m => ({ ...m })),
|
||||
}
|
||||
@@ -1377,16 +1402,29 @@ async function testModel(btn, state, providerKey, idx) {
|
||||
model.testStatus = 'ok'
|
||||
delete model.testError
|
||||
}
|
||||
toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success')
|
||||
// 包含 ⚠ 的是非致命错误(429 等),拆分显示
|
||||
if (reply.startsWith('⚠')) {
|
||||
const lines = reply.split('\n')
|
||||
const summary = lines[0]
|
||||
const detail = lines.slice(1).join('\n').trim()
|
||||
if (detail) {
|
||||
const detailHtml = detail.replace(/</g, '<').replace(/(https?:\/\/[^\s,,。;))'"&]+)/g, '<a href="$1" target="_blank" style="color:var(--primary);text-decoration:underline">$1</a>')
|
||||
toast(`<strong>${modelId}</strong> ${summary.replace(/</g, '<')}<br><span style="font-size:11px;line-height:1.5;word-break:break-all">${detailHtml}</span>`, 'warning', { duration: 10000, html: true })
|
||||
} else {
|
||||
toast(`${modelId} ${summary}`, 'warning', { duration: 6000 })
|
||||
}
|
||||
} else {
|
||||
toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
const elapsed = Date.now() - start
|
||||
if (typeof model === 'object') {
|
||||
model.latency = null
|
||||
model.lastTestAt = Date.now()
|
||||
model.testStatus = 'fail'
|
||||
model.testError = String(e).slice(0, 100)
|
||||
model.testError = String(e).slice(0, 200)
|
||||
}
|
||||
toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error')
|
||||
toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error', { duration: 8000 })
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = origText
|
||||
|
||||
@@ -78,19 +78,30 @@ async function loadSkills(page) {
|
||||
function renderSkills(el, data) {
|
||||
const skills = data?.skills || []
|
||||
const cliAvailable = data?.cliAvailable !== false
|
||||
const source = data?.source || ''
|
||||
const cliDiag = data?.diagnostic?.cli || null
|
||||
const eligible = skills.filter(s => s.eligible && !s.disabled)
|
||||
const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
|
||||
const disabled = skills.filter(s => s.disabled)
|
||||
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
|
||||
|
||||
const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
|
||||
let sourceHint = ''
|
||||
if (source === 'local-scan') {
|
||||
if (cliDiag?.status === 'timeout') sourceHint = 'CLI 可用,但本次调用超时,当前显示本地扫描结果'
|
||||
else if (cliDiag?.status === 'parse-failed') sourceHint = 'CLI 可用,但返回结果解析失败,当前显示本地扫描结果'
|
||||
else if (cliDiag?.status === 'exec-failed') sourceHint = 'CLI 调用失败,当前显示本地扫描结果'
|
||||
else sourceHint = cliAvailable ? '当前显示本地扫描结果' : 'CLI 不可用,当前显示本地扫描结果'
|
||||
} else if (cliAvailable) {
|
||||
sourceHint = '当前已使用 OpenClaw CLI 结果'
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="clawhub-toolbar">
|
||||
<input class="input clawhub-search-input" id="skill-filter-input" placeholder="过滤 Skills..." type="text">
|
||||
<button class="btn btn-secondary btn-sm" data-action="skill-retry">刷新</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://clawhub.ai/skills" target="_blank" rel="noopener">ClawHub</a>
|
||||
${!cliAvailable ? '<span class="form-hint" style="margin-left:auto;color:var(--warning)">CLI 不可用,仅显示本地扫描结果</span>' : ''}
|
||||
${sourceHint ? `<span class="form-hint" style="margin-left:auto;color:${source === 'local-scan' ? 'var(--warning)' : 'var(--text-tertiary)'}">${esc(sourceHint)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="skills-summary" style="margin-bottom:var(--space-lg);color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
@@ -136,7 +147,7 @@ function renderSkills(el, data) {
|
||||
<div class="clawhub-panel">
|
||||
<div class="clawhub-empty" style="text-align:center;padding:var(--space-xl)">
|
||||
<div style="margin-bottom:var(--space-sm)">未检测到任何 Skills</div>
|
||||
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供,也可自定义放置在 <code>~/.openclaw/skills/</code> 目录下。</div>
|
||||
<div class="form-hint">请确认 OpenClaw 已正确安装。Skills 随 OpenClaw 捆绑提供;自定义 Skills 可能位于 <code>~/.openclaw/skills/</code> 或 <code>~/.claude/skills/</code>。</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
|
||||
@@ -480,17 +480,33 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 断连横幅 */
|
||||
/* 断连提示:细条 + 中性色,与聊天区融合,不抢视觉焦点 */
|
||||
.chat-disconnect-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.chat-disconnect-bar::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.65;
|
||||
animation: chat-disconnect-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chat-disconnect-pulse {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.92); }
|
||||
50% { opacity: 0.85; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* 连接引导遮罩 */
|
||||
|
||||
@@ -432,48 +432,83 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Gateway 未启动引导横幅 */
|
||||
/* Gateway 状态条:低调信息提示,避免高对比琥珀色造成焦虑 */
|
||||
.gw-banner {
|
||||
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #78350f;
|
||||
padding: 8px 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding: 5px 12px;
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
z-index: 100;
|
||||
transition: all 300ms ease;
|
||||
transition: max-height 280ms ease, padding 280ms ease, opacity 200ms ease, border-color 200ms ease;
|
||||
overflow: hidden;
|
||||
max-height: 80px;
|
||||
max-height: 72px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.gw-banner-hidden {
|
||||
max-height: 0;
|
||||
padding: 0 16px;
|
||||
padding: 0 12px;
|
||||
opacity: 0;
|
||||
border-bottom-color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gw-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gw-banner-icon {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gw-banner-icon svg {
|
||||
stroke: var(--text-tertiary) !important;
|
||||
}
|
||||
.gw-banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(0,0,0,.5);
|
||||
font-size: 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
margin-left: 8px;
|
||||
transition: color .15s;
|
||||
padding: 0 2px;
|
||||
margin-left: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.gw-banner-close:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-glass-hover);
|
||||
}
|
||||
.gw-banner-close:hover { color: rgba(0,0,0,.8); }
|
||||
.gw-banner .btn {
|
||||
margin-left: auto;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
padding: 3px 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.gw-banner .btn:hover {
|
||||
background: var(--bg-glass-hover);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
.gw-banner .btn-ghost {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 400;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.gw-banner .btn-ghost:hover {
|
||||
color: var(--text-primary) !important;
|
||||
background: var(--bg-glass) !important;
|
||||
}
|
||||
|
||||
/* === 移动端顶栏 + 侧边栏 === */
|
||||
|
||||
@@ -1147,6 +1147,62 @@
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Agent 对接 — 渠道绑定卡片 */
|
||||
.agent-binding-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.agent-binding-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.agent-binding-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
.agent-binding-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.agent-binding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.agent-binding-row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.agent-binding-channel {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.agent-binding-row-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.platform-pick-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -106,3 +106,28 @@
|
||||
--sidebar-collapsed: 60px;
|
||||
--header-height: 52px;
|
||||
}
|
||||
|
||||
/* 主题切换圆形扩散动画 (View Transitions API) */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
animation: themeCircleReveal 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes themeCircleReveal {
|
||||
from {
|
||||
clip-path: circle(0% at var(--theme-reveal-x, 0%) var(--theme-reveal-y, 100%));
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at var(--theme-reveal-x, 0%) var(--theme-reveal-y, 100%));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,23 @@ import { homedir } from 'os'
|
||||
const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
|
||||
|
||||
// 读取 Gateway 端口(启动时读取一次)
|
||||
// 注意:Gateway 默认端口是 18789,不是 18790
|
||||
let gatewayPort = 18789
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(homedir(), '.openclaw', 'openclaw.json'), 'utf8'))
|
||||
gatewayPort = cfg?.gateway?.port || 18789
|
||||
} catch {}
|
||||
const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json')
|
||||
if (fs.existsSync(cfgPath)) {
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
|
||||
// 端口必须 > 0 且 < 65536
|
||||
const port = cfg?.gateway?.port
|
||||
if (port && typeof port === 'number' && port > 0 && port < 65536) {
|
||||
gatewayPort = port
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[vite] 读取 Gateway 端口配置失败,使用默认端口 18789:', e.message)
|
||||
}
|
||||
|
||||
console.log(`[vite] Gateway WebSocket 代理目标: ws://127.0.0.1:${gatewayPort}`)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [devApiPlugin()],
|
||||
@@ -27,8 +39,23 @@ export default defineConfig({
|
||||
'/ws': {
|
||||
target: `ws://127.0.0.1:${gatewayPort}`,
|
||||
ws: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('error', () => {})
|
||||
changeOrigin: true,
|
||||
timeout: 30000,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
|
||||
socket.setTimeout(30000)
|
||||
socket.on('timeout', () => {
|
||||
console.warn('[vite/ws] WebSocket 超时,关闭连接')
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
proxy.on('error', (err, req, socket) => {
|
||||
console.warn(`[vite/ws] 代理错误: ${err.code} ${err.message}`)
|
||||
// WebSocket 升级后 socket 是 net.Socket,无 headersSent
|
||||
if (socket && !socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user