feat: ClawPal v0.1 — Tauri desktop GUI for OpenClaw

4-page layout (Home, Recipes, Settings, Doctor) with sidebar nav
and integrated Chat panel powered by OpenClaw agent (--local).

- Home: status, agents overview, recommended recipes, recent activity
- Recipes: browse, preview diff, apply with params
- Settings: model profiles CRUD, chat model selection, provider catalog
- Doctor: diagnostics with auto-fix
- Chat: OpenClaw agent integration with session persistence,
  agent selector, read-only advisory context injection
- Progressive data loading to avoid UI blocking
- API key resolution from OpenClaw agent auth-profiles
- Model catalog from openclaw CLI with cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhixian
2026-02-17 01:16:38 +09:00
commit 700c5e9ab2
53 changed files with 20292 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
src-tauri/target/

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# ClawPal MVP (Tauri)
ClawPal is a local helper for OpenClaw configuration:
- install scenarios via Recipes
- one-click rollback for every config change
- local doctor checks with basic auto-fixes
## Quick start
```bash
npm install
npm run dev
```
### Override folders outside `~/.openclaw`
You can place ClawPal-managed files outside `~/.openclaw` with env vars:
```bash
export CLAWPAL_OPENCLAW_DIR="$HOME/.openclaw" # OpenClaw 配置来源目录(默认)
export CLAWPAL_DATA_DIR="$HOME/.clawpal" # ClawPal 元数据目录(默认: $CLAWPAL_OPENCLAW_DIR/.clawpal
```
## Build
```bash
npm run build
cd src-tauri && cargo build
```
## Release
```bash
npm run release:dry-run
npm run release
```
## Project layout
- `src/` React + TypeScript UI
- `src-tauri/` Rust + Tauri host and command APIs
- `docs/plans/` design and implementation plan

66
agents.md Normal file
View File

@@ -0,0 +1,66 @@
# ClawPal 开发规范agents.md
## 1. 仓库约定
- 使用 Git 进行所有变更追踪
- 统一采用 UTF-8 编码
- 变更以原子提交为粒度,避免一次提交包含多个互不相关需求
## 2. 分支与 PR
- `main`: 受保护主线
- `feat/*`: 新功能(示例:`feat/recipe-preview`
- `fix/*`: 缺陷修复(示例:`fix/rollback-edge-case`
- `chore/*`: 工具/流程/文档维护
提交前确保:
- 运行相关的类型检查/构建脚本(如有)
- 更新相关文档(需要时)
## 3. 提交规范
使用 Conventional Commits
- `feat:` 新功能
- `fix:` Bug 修复
- `docs:` 文档
- `refactor:` 重构
- `chore:` 维护
示例:
- `feat: add recipe preview diff panel`
- `fix: avoid duplicate snapshot id collisions`
## 4. 开发流程
每次变更建议按以下顺序执行:
1. 明确需求和验收标准
2. 先做最小实现
3. 自检关键流程读取配置、预览、应用、回滚、Doctor
4. 同步更新文档
5. 提交并标记未完成项
## 5. 代码质量要求
- 函数尽量短、职责单一
- 对外行为需具备错误返回,不抛出未处理异常
- 新增参数/结构体需有默认值或向后兼容路径
- 优先保持最小可运行状态再逐步演进
## 6. 任务追踪
建议在每轮开发前补充:
- 当前任务目标
- 预期验收项
- 完成后状态(完成 / 待验收)
可用文件:
- `docs/mvp-checklist.md`(验收)
- `docs/plans/2026-02-15-clawpal-mvp-design.md`(设计)
- `docs/plans/2026-02-15-clawpal-mvp-implementation-plan.md`(计划)
## 7. 安全与风险
- 禁止提交明文密钥/配置路径泄露
- 避免大文件和自动生成产物直接提交
-`~/.openclaw` 的读写逻辑需包含异常回退和用户可见提示

564
design.md Normal file
View File

@@ -0,0 +1,564 @@
# ClawPal Design Document
> OpenClaw 配置助手 — 让普通用户也能玩转高级配置
## 1. 产品定位
### 问题
- OpenClaw 配置功能强大但复杂
- 官方 Web UI 是"配置项罗列",用户看晕
- 用户让 Agent 自己配置,经常出错
- 配置出错时 Gateway 起不来,陷入死循环
### 解决方案
**场景驱动的配置助手**
- 不是"列出所有配置项",而是"你想实现什么场景?"
- 用户选场景 → 填几个参数 → 一键应用
- 独立运行,不依赖 Gateway配置坏了也能修
### 核心价值
1. **降低门槛** — 普通用户也能用上高级功能
2. **最佳实践** — 社区沉淀的配置方案,一键安装
3. **急救工具** — 配置出问题时的救命稻草
4. **版本控制** — 改坏了一键回滚
## 2. 产品架构
```
┌─────────────────────────────────────────────────────────┐
│ clawpal.dev (官网) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Recipe │ │ Recipe │ │ Recipe │ │ Recipe │ │
│ │ Card │ │ Card │ │ Card │ │ Card │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ └────────────┴─────┬──────┴────────────┘ │
│ │ │
│ [一键安装按钮] │
│ │ │
└───────────────────────────┼─────────────────────────────┘
│ clawpal://install/recipe-id
┌─────────────────────────────────────────────────────────┐
│ ClawPal App (本地) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 首页 │ │
│ │ ┌─────────┐ 当前配置健康状态: ✅ 正常 │ │
│ │ │ 状态 │ OpenClaw 版本: 2026.2.13 │ │
│ │ │ 卡片 │ 活跃 Agents: 4 │ │
│ │ └─────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 场景库 │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Discord │ │ Telegram│ │ 模型 │ │ │
│ │ │ 人设 │ │ 配置 │ │ 切换 │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 历史记录 │ │
│ │ ● 2026-02-15 21:30 应用了 "Discord 人设" │ │
│ │ ● 2026-02-15 20:00 手动编辑 │ │
│ │ ● 2026-02-14 15:00 应用了 "性能优化" │ │
│ │ [回滚到此版本] │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────────────┬──────────────────────────────┘
│ 直接读写(不依赖 Gateway
~/.openclaw/openclaw.json
```
## 3. 核心功能
### 3.1 场景库 (Recipes)
每个 Recipe 是一个"配置方案",包含:
- 标题、描述、标签
- 需要用户填的参数
- 配置补丁模板
**示例 RecipeDiscord 频道专属人设**
```yaml
id: discord-channel-persona
name: "Discord 频道专属人设"
description: "给特定 Discord 频道注入专属 system prompt让 Agent 在不同频道表现不同"
author: "zhixian"
version: "1.0.0"
tags: ["discord", "persona", "beginner"]
difficulty: "easy"
# 用户需要填的参数
params:
- id: guild_id
label: "服务器 ID"
type: string
placeholder: "右键服务器 → 复制服务器 ID"
- id: channel_id
label: "频道 ID"
type: string
placeholder: "右键频道 → 复制频道 ID"
- id: persona
label: "人设描述"
type: textarea
placeholder: "在这个频道里,你是一个..."
# 配置补丁JSON Merge Patch 格式)
patch: |
{
"channels": {
"discord": {
"guilds": {
"{{guild_id}}": {
"channels": {
"{{channel_id}}": {
"systemPrompt": "{{persona}}"
}
}
}
}
}
}
}
```
### 3.2 引导式安装流程
```
[选择场景] → [填写参数] → [预览变更] → [确认应用] → [完成]
│ │ │ │
│ │ │ └── 自动备份当前配置
│ │ └── Diff 视图,清晰展示改了什么
│ └── 表单 + 实时校验
└── 卡片式浏览,带搜索/筛选
```
### 3.3 版本控制 & 回滚
```
~/.openclaw/
├── openclaw.json # 当前配置
└── .clawpal/
├── history/
│ ├── 2026-02-15T21-30-00_discord-persona.json
│ ├── 2026-02-15T20-00-00_manual-edit.json
│ └── 2026-02-14T15-00-00_performance-tuning.json
└── metadata.json # 历史记录元数据
```
**回滚流程**
1. 选择历史版本
2. 展示 Diff当前 vs 目标版本)
3. 确认回滚
4. 当前版本也存入历史(防止误操作)
### 3.4 配置诊断 (Doctor)
当 Gateway 起不来时ClawPal 可以独立运行诊断:
**检查项**
- [ ] JSON 语法是否正确
- [ ] 必填字段是否存在
- [ ] 字段类型是否正确
- [ ] 端口是否被占用
- [ ] 文件权限是否正确
- [ ] Token/密钥格式是否正确
**自动修复**
- 语法错误:尝试修复常见问题(尾逗号、引号)
- 缺失字段:填充默认值
- 格式错误:自动转换
## 4. 官网设计
### 4.1 首页
```
┌─────────────────────────────────────────────────────────┐
│ ClawPal │
│ 让 OpenClaw 配置变得简单 │
│ │
│ [下载 App] [浏览 Recipes] │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 热门 Recipes │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │ 🎭 │ │ ⚡ │ │ 🔔 │ │ 🤖 │ │ 📝 │ │ │
│ │ │人设 │ │性能 │ │提醒 │ │模型 │ │日记 │ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 提交你的 Recipe │ │
│ │ 分享你的最佳实践,帮助更多人 │ │
│ │ [提交] │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 4.2 Recipe 详情页
```
┌─────────────────────────────────────────────────────────┐
│ ← 返回 │
│ │
│ Discord 频道专属人设 v1.0.0 │
│ by zhixian │
│ │
│ ⬇️ 1,234 安装 ⭐ 4.8 (56 评价) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 给特定 Discord 频道注入专属 system prompt │ │
│ │ 让 Agent 在不同频道表现不同。 │ │
│ │ │ │
│ │ 适用场景: │ │
│ │ • 工作频道严肃,闲聊频道轻松 │ │
│ │ • 不同频道不同语言 │ │
│ │ • 特定频道禁用某些功能 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 需要填写的参数: │
│ • 服务器 ID │
│ • 频道 ID │
│ • 人设描述 │
│ │
│ [在 ClawPal 中安装] │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ 配置预览 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ channels: │ │
│ │ discord: │ │
│ │ guilds: │ │
│ │ "{{guild_id}}": │ │
│ │ channels: │ │
│ │ "{{channel_id}}": │ │
│ │ systemPrompt: "{{persona}}" │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 4.3 Deep Link 协议
```
clawpal://install/{recipe-id}
clawpal://install/{recipe-id}?source=web&version=1.0.0
```
App 收到 deep link 后:
1. 下载 recipe 元数据
2. 打开安装向导
3. 引导用户填写参数
4. 应用配置
## 5. 技术栈
### 5.1 本地 App
```
ClawPal App (Tauri)
├── src-tauri/ # Rust 后端(轻量,主要用 Tauri API
│ ├── src/
│ │ └── main.rs # 入口 + 少量原生逻辑
│ └── tauri.conf.json # Tauri 配置
└── src/ # Web 前端
├── App.tsx
├── pages/
│ ├── Home.tsx # 首页 + 状态
│ ├── Recipes.tsx # 场景库
│ ├── Install.tsx # 安装向导
│ ├── History.tsx # 历史记录
│ └── Doctor.tsx # 诊断修复
├── components/
│ ├── RecipeCard.tsx
│ ├── ParamForm.tsx
│ ├── DiffViewer.tsx
│ └── ...
└── lib/
├── config.ts # 配置读写(用 Tauri fs API
├── recipe.ts # Recipe 解析/应用
├── backup.ts # 版本控制
└── doctor.ts # 诊断逻辑
```
### 5.2 技术选型
| 组件 | 选型 | 理由 |
|------|------|------|
| App 框架 | Tauri 2.0 | 轻量(5-10MB)JS 为主 |
| 前端框架 | React + TypeScript | 生态成熟 |
| UI 组件 | shadcn/ui | 好看,可定制 |
| 状态管理 | React Context + useReducer | 先用原生,后续再引入 Zustand |
| 配置解析 | json5 | 支持注释 |
| Diff 展示 | monaco-editor diff | 可控性强,定制成本低 |
### 5.3 RecipeEngine 核心接口
```typescript
interface RecipeEngine {
// 校验 recipe 定义 + 用户参数
validate(recipe: Recipe, params: Record<string, unknown>): ValidationResult;
// 预览变更(不实际修改)
preview(recipe: Recipe, params: Record<string, unknown>): PreviewResult;
// 应用配置(自动备份)
apply(recipe: Recipe, params: Record<string, unknown>): ApplyResult;
// 回滚到指定快照
rollback(snapshotId: string): RollbackResult;
// 从损坏状态恢复
recover(): RecoverResult;
}
interface PreviewResult {
diff: string; // 配置 Diff
impactLevel: 'low' | 'medium' | 'high'; // 影响级别
affectedPaths: string[]; // 受影响的配置路径
canRollback: boolean; // 是否可回滚
overwritesExisting: boolean; // 是否覆盖现有配置
warnings: string[]; // 警告信息
}
```
### 5.3 官网
| 组件 | 选型 | 理由 |
|------|------|------|
| 框架 | Next.js | SSR/SSGSEO 友好 |
| 部署 | Vercel / Cloudflare Pages | 免费CDN |
| 数据库 | Supabase / PlanetScale | Recipe 存储 |
| 认证 | GitHub OAuth | 用户提交 recipe |
## 6. MVP 范围(精简版)
> 先做 3 个高价值核心功能,离线可用,快速验证
### MVP 核心功能
#### 1. 安装向导
- [ ] 参数校验schema 验证)
- [ ] 变更预览Diff 视图)
- [ ] 应用配置
- [ ] 自动备份
#### 2. 版本快照与回滚
- [ ] 每次修改前自动快照
- [ ] 历史记录列表
- [ ] 一键回滚
- [ ] 回滚前预览 Diff
#### 3. 配置诊断
- [ ] JSON 语法检查
- [ ] 必填字段验证
- [ ] 端口占用检测
- [ ] 文件权限检查
- [ ] 一键修复 + 显示变更原因
### MVP 不做的事
- ❌ 官网
- ❌ 用户系统 / OAuth
- ❌ 评分/评论体系
- ❌ 在线 Recipe 仓库
### 后续阶段
- Phase 2: 官网 + Recipe 在线分发
- Phase 3: 社区功能(评分、评论、用户提交)
## 7. 初始 Recipe 列表
MVP 内置的 Recipes
1. **Discord 频道专属人设** — 不同频道不同性格
2. **Telegram 群组配置** — 群聊 mention 规则
3. **定时任务配置** — Heartbeat + Cron 基础设置
4. **模型切换** — 快速切换默认模型
5. **性能优化** — contextPruning + compaction 最佳实践
---
## 8. 风险点 & 注意事项
### 8.1 Schema 版本兼容
- OpenClaw 配置 schema 会随版本变化
- 需要锁定版本兼容层v1/v2 schema migration
- Recipe 需标注兼容的 OpenClaw 版本范围
### 8.2 安全性
- **深度链接可信源校验**:防止恶意 recipe 写入本地配置
- **敏感路径白名单**:限制 recipe 可修改的配置路径
- **危险操作提醒**:涉及 token、密钥、敏感路径时 must-have 确认
### 8.3 平台兼容
- Tauri 2.0 在 Windows/macOS 路径权限表现有差异
- 需要测试不同平台的文件读写行为
- 路径处理使用 Tauri 的跨平台 API
### 8.4 WSL2 支持Windows 重点)
很多 Windows 用户通过 WSL2 安装 OpenClaw配置文件在 Linux 文件系统里。
**检测逻辑**
1. 检查 Windows 原生路径 `%USERPROFILE%\.openclaw\`
2. 如果不存在,扫描 `\\wsl$\*\home\*\.openclaw\`
3. 找到多个时让用户选择
**路径映射**
```
WSL2 路径: /home/user/.openclaw/openclaw.json
Windows 访问: \\wsl$\Ubuntu\home\user\.openclaw\openclaw.json
```
**UI 处理**
- 首次启动检测安装方式
- 设置页可手动切换/指定路径
- 显示当前使用的路径来源Windows / WSL2-Ubuntu / 自定义)
### 8.5 JSON5 风格保持
- 用户手写的注释和缩进不能被破坏
- 写回时需保持原有格式风格
- 考虑使用 AST 级别的修改而非 stringify
---
## 9. Recipe 校验规则
### 9.1 参数 Schema
```yaml
params:
- id: guild_id
type: string
required: true
pattern: "^[0-9]+$" # 正则校验
minLength: 17
maxLength: 20
```
### 9.2 路径白名单
```yaml
# 只允许修改这些路径
allowedPaths:
- "channels.*"
- "agents.defaults.*"
- "agents.list[*].identity"
# 禁止修改
forbiddenPaths:
- "gateway.auth.*" # 认证相关
- "*.token" # 所有 token
- "*.apiKey" # 所有 API key
```
### 9.3 危险操作标记
```yaml
dangerousOperations:
- path: "gateway.port"
reason: "修改端口可能导致连接中断"
requireConfirm: true
- path: "channels.*.enabled"
reason: "禁用频道会影响消息收发"
requireConfirm: true
```
---
## 10. 体验细节
### 10.1 影响级别展示
安装按钮显示"预估影响级别"
| 级别 | 条件 | 展示 |
|------|------|------|
| 🟢 低 | 只添加新配置,不修改现有 | "添加新配置" |
| 🟡 中 | 修改现有配置,可回滚 | "修改配置(可回滚)" |
| 🔴 高 | 涉及敏感路径或大范围修改 | "重要变更(请仔细检查)" |
### 10.2 可回滚提示
每个 Recipe 显示:
- ✅ 可回滚 / ⚠️ 部分可回滚 / ❌ 不可回滚
- 是否会覆盖现有配置(高亮显示冲突项)
### 10.3 历史记录增强
- 关键词筛选
- 仅显示可回滚节点
- 按 Recipe 类型分组
### 10.4 Doctor 一键修复
```
发现 2 个问题:
1. ❌ JSON 语法错误(第 42 行)
→ 多余的逗号
[一键修复] 删除第 42 行末尾的逗号
2. ❌ 必填字段缺失
→ agents.defaults.workspace 未设置
[一键修复] 设置为默认值 "~/.openclaw/workspace"
[全部修复] [仅修复语法] [查看变更详情]
```
---
## 11. 落地步骤(推荐顺序)
### Step 1: RecipeEngine 核心
1. 定义 RecipeEngine 接口
2. 实现 `validate``preview``apply``rollback``recover`
3. 编写单元测试
### Step 2: 端到端流程验证
1. 实现一个真实 RecipeDiscord 人设)
2. 完整走通:选择 → 填参数 → 预览 → 应用 → 回滚
3. 验证 JSON5 风格保持
### Step 3: 损坏恢复演练
1. 模拟配置损坏场景
2. 测试 Doctor 诊断流程
3. 验证一键修复功能
### Step 4: 扩展 & 发布
1. 添加 2-3 个 Recipe
2. 完善 UI 细节
3. 打包发布macOS / Windows / Linux
---
## 附录
### A. 隐藏但有用的配置能力
这些是 OpenClaw 支持但用户不一定知道的功能:
| 功能 | 配置路径 | 说明 |
|------|----------|------|
| Channel 级 systemPrompt | `channels.*.guilds.*.channels.*.systemPrompt` | 频道专属人设 |
| Context Pruning | `agents.defaults.contextPruning` | 上下文裁剪策略 |
| Compaction | `agents.defaults.compaction` | Session 压缩 |
| Bindings | `bindings[]` | 按条件路由到不同 Agent |
| Media Audio | `tools.media.audio` | 语音转录配置 |
| Memory Search | `agents.defaults.memorySearch` | 记忆搜索配置 |
### B. 文件路径
| 文件 | 路径 |
|------|------|
| OpenClaw 配置 | `~/.openclaw/openclaw.json` |
| ClawPal 历史 | `~/.openclaw/.clawpal/history/` |
| ClawPal 元数据 | `~/.openclaw/.clawpal/metadata.json` |
---
*Last updated: 2026-02-15*

47
docs/mvp-checklist.md Normal file
View File

@@ -0,0 +1,47 @@
# ClawPal MVP 验收清单
## 1. 安装向导
- [x] 打开 Recipes 列表
- [x] 选择一个 Recipe
- [x] 参数校验阻止非法输入
- [x] 点击 Preview 显示变更
- [x] 点击 Apply 成功写入并生成历史快照
## 2. 历史与回滚
- [x] 历史列表可见最近记录
- [x] 选中历史项可预览回滚 diff
- [x] 执行回滚后回填新快照(用于再次回滚)
- [x] 回滚后配置文件发生可见变化
## 3. Doctor
- [x] 运行 Doctor 返回至少一项问题(如有)
- [x] 对语法/字段问题展示修复建议
- [x] auto-fix 的问题可点击 fix状态刷新
- [x] 关键问题导致状态 score 下降
## 4. 可交付性
- [x] 无需网络也能完成核心流程
- [x] 目录存在于 `~/.openclaw`,历史文件落在 `.clawpal/history`
- [x] `npm run build` 成功
- [ ] `npm run release:dry-run` 输出通过项(无需执行发布)
## 5. 模型与频道管理v0.2
- [x] 模型 Profile 支持列表、创建、更新、删除
- [x] 全局模型绑定可设置与清空
- [x] Agent 模型覆盖可设置与清空
- [x] Channel 模型绑定可设置与清空
- [x] Channel 节点可更新 `type/mode/allowlist/model`
- [x] Channel 节点可安全删除
- [x] Recipes 支持外部文件/URL 源加载
## 6. Memory 与 Session 管理v0.2
- [x] Memory 文件列表可见
- [x] Memory 单文件删除与清空可用
- [x] Session 文件列表可见active + archive
- [x] Session 单文件删除与按 agent/全部清空可用

View File

@@ -0,0 +1,279 @@
# ClawPal MVP 设计文档(实现版)
日期2026-02-15
版本MVP-1.0
目标:用最小投入实现可用产品,覆盖 `design.md` 中 MVP 核心范围(安装向导、快照与回滚、配置诊断)。
## 1. 范围边界
### 1.1 本版实现范围MVP
- 安装向导
- Recipe 列表(内置静态 Recipes
- 参数 schema 校验必填、类型、pattern
- 预览变更diff
- 应用配置(含自动备份)
- 版本控制与回滚
- 每次写入前自动快照
- 历史记录列表
- 选中历史版本回滚(回滚前显示 diff
- 回滚过程二次确认
- 配置诊断Doctor
- JSON 解析
- 必填字段检查
- 端口占用(仅基础检查)
- 文件权限检查(读写)
- 一键修复建议(语法修复、默认值补齐)
### 1.2 明确不做
- 官网、用户系统、在线提交与评论
- 复杂的配置兼容迁移v1/v2
- 深度路径白名单策略(保留可配置白名单雏形)
- 插件化/市场化的远程 Recipe 发布
## 2. 目标架构Tauri 2 + React
### 2.1 分层
- `src-tauri/src`
- `main.rs`:初始化、窗口配置、命令注册
- `commands.rs`:所有跨端调用入口
- `recipe.rs`Recipe 定义、参数校验、模板渲染
- `config_io.rs`配置读写路径发现、json5 解析、备份文件读写)
- `history.rs`:快照目录与元数据管理
- `doctor.rs`:诊断与修复策略
- `src/`
- `pages/`
- `Home.tsx`:健康状态、版本、按钮入口
- `Recipes.tsx`:卡片列表与搜索过滤
- `Install.tsx`:安装向导
- `History.tsx`:历史快照
- `Doctor.tsx`:问题列表与修复动作
- `components/`
- `RecipeCard.tsx`
- `ParamForm.tsx`
- `DiffViewer.tsx`
- `StatusPill.tsx`
- `lib/`
- `recipe_catalog.ts`:内置 recipes内嵌 JSON 或 TS 常量)
- `api.ts`Tauri command 调用封装
- `state.ts``Context + useReducer` 统一状态与副作用
### 2.2 目录与文件
```
~/.openclaw/openclaw.json # 当前配置
~/.openclaw/.clawpal/ # 本地状态目录
~/.openclaw/.clawpal/history/
~/.openclaw/.clawpal/metadata.json # 快照元信息(列表可直接读)
```
## 3. Recipe 与配置模型
### 3.1 Recipe 核心结构
```ts
interface Recipe {
id: string;
name: string;
description: string;
version: string;
tags: string[];
difficulty: 'easy' | 'normal' | 'advanced';
params: RecipeParam[];
patchTemplate: string; // JSON Merge Patch with {{param}}
impact: {
category: 'low' | 'medium' | 'high';
summary: string;
};
}
interface RecipeParam {
id: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'textarea';
required: boolean;
pattern?: string;
minLength?: number;
maxLength?: number;
placeholder?: string;
}
```
### 3.2 预览与应用结果
```ts
interface PreviewResult {
recipeId: string;
diff: string;
changes: ChangeItem[];
overwritesExisting: boolean;
canRollback: boolean; // true = 已生成快照
impactLevel: 'low' | 'medium' | 'high';
warnings: string[];
}
interface ChangeItem {
path: string;
op: 'add' | 'replace' | 'remove';
risk: 'low' | 'medium' | 'high';
reason?: string;
}
```
## 4. Tauri Command 设计
### 4.1 命令边界
- `get_system_status(): SystemStatus`
- `list_recipes(): Recipe[]`
- `preview_apply(recipe_id: string, params: Record<string, string>): PreviewResult`
- `apply_recipe(recipe_id: string, params: Record<string, string>): ApplyResult`
- `list_history(limit: number, offset: number): HistoryPage`
- `preview_rollback(snapshot_id: string): PreviewResult`
- `rollback(snapshot_id: string): ApplyResult`
- `run_doctor(): DoctorReport`
- `fix_issues(issue_ids: string[]): FixResult`
- `open_config_path(): string`
### 4.2 关键返回结构
```ts
interface ApplyResult {
ok: boolean;
snapshotId?: string; // 回滚锚点
configPath: string;
backupPath?: string;
warnings: string[];
errors?: string[];
}
interface DoctorReport {
ok: boolean;
issues: DoctorIssue[];
score: number; // 0-100 健康值
}
interface DoctorIssue {
id: string;
code: string;
severity: 'error' | 'warn' | 'info';
message: string;
autoFixable: boolean;
fixHint?: string;
}
```
## 5. 核心逻辑算法
### 5.1 参数校验
1. Recipe 找不到 -> 错误
2. 参数逐个校验:
- 必填
- 类型
- pattern正则
- 长度
3. 校验通过后进入渲染
### 5.2 参数渲染与差异生成
1.`{{param_id}}` 进行文本替换
2. 解析 base 配置与 patch 模板json5
3. 进行深合并merge patch
4. Diff 生成:
- 将当前配置与待应用配置序列化为 pretty JSON
- 输出 unified diff 或关键节点差异
### 5.3 应用流程Write path
1. 调用 preview
2. 创建快照:
- 将当前 `openclaw.json` 复制到 `.clawpal/history/<ts>_<slug>.json`
- 更新 `metadata.json`(按时间倒序)
3. 原子写新配置:
- 临时文件写入 -> rename 覆盖
4. 失败回滚:
- 临时文件清理
- 保留快照,但不上报成功
### 5.4 回滚流程
1. 读取目标快照
2. 计算与当前配置 diff
3. 确认后再执行快照(当前入历史)
4. 用目标快照替换当前配置
### 5.5 Doctor 与修复
- 语法错误:尝试修复尾逗号、未闭合引号等常见问题
- 关键字段缺失:按最小安全默认值补齐(仅在用户确认后)
- 端口占用:读取端口字段并做最小冲突提示(非阻塞 warning
- 权限问题:展示“文件不可读/不可写+路径来源”建议
## 6. 安全与约束MVP 简化)
- 禁止修改路径初版:
- `gateway.auth.*`
- `*.token`
- `*.apiKey`
- `dangerous` 字段提示:
- `gateway.port` 修改前增加二次确认
- Deep Link 与远程 Recipe 不支持MVP 不接入官网)
- 所有写操作必须先写快照,`apply_result.snapshotId` 作为审计锚点
## 7. 兼容与平台策略
- 路径优先级:
1. `~/.openclaw/.clawpal`(首次初始化)
2. 现有配置路径检测(若 `.openclaw` 已存在则复用)
3. WSL2 仅作“只读检测入口”MVP 只读展示,不做深度自动映射)
- Windows/macOS/Linux 使用 Tauri 提供的跨平台路径 API
## 8. 内置 RecipeMVP
1. Discord 频道专属人设
2. Telegram 群组配置
3. 定时任务配置
4. 模型切换
5. 性能优化
每个 Recipe 均含:
- id/name/version/params/patchTemplate
- default 示例值与最小校验规则
- impact 元信息(影响等级)
## 9. 里程碑与验收
### Milestone A第 1 阶段)
- 项目脚手架搭建
- Recipe 解析与参数校验
- 安装向导基础路径可跑通
- 最小 Diff 预览
- 验收:选 1 个 Recipe 完整完成 “填参数->预览->应用”
### Milestone B第 2 阶段)
- 快照元数据与历史列表
- 回滚预览与回滚执行
- 验收:应用后 1 步回滚成功且 current 状态可恢复
### Milestone C第 3 阶段)
- Doctor 基础扫描与修复入口
- 端口/权限/语法检查
- 验收:制造 1 处语法错误,修复后能读取启动
## 10. 关键风险与回退方案
- JSON5 风格保持未完全解决MVP 采用“标准化写回”,后续引入更精细 AST 修改
- 端口占用检测可能误报:提示“仅警告”不阻断
- Recipe 深入语义冲突:通过 change list 显示冲突路径并要求确认
- 若配置文件损坏到不可修复:保留备份、提示手工恢复和重建路径
## 11. 交付标准
- 所有命令返回可序列化错误码,不出现裸异常弹窗
- 每次 apply 成功都生成可回滚快照(除极端写入错误)
- 历史列表支持 `时间/来源/描述` 查看
- Doctor 能给出至少一条可执行修复动作
- 无法修复时给出建议与重试按钮

View File

@@ -0,0 +1,403 @@
# ClawPal MVP Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Deliver a working Tauri MVP for ClawPal that implements install wizard, snapshot/rollback, and doctor repair flows with data-safe write operations.
**Architecture:** A thin React UI orchestrates a Rust-backed command API in Tauri; all configuration reads/writes, preview/rollback logic, and diagnosis checks are implemented in Rust for deterministic behavior and simpler cross-platform filesystem handling.
**Tech Stack:** Tauri 2, Rust, React 18, TypeScript, json5, monaco-editor (diff), Vitest, Cargo test
---
### Task 1: 初始化项目骨架与基础命令骨架
**Files:**
- Create: `package.json`
- Create: `src-tauri/Cargo.toml`
- Create: `src-tauri/src/main.rs`
- Create: `src-tauri/src/commands.rs`
- Create: `src-tauri/src/lib.rs`
- Create: `src-tauri/src/commands_tests.rs`
- Create: `src/main.tsx`
- Create: `src/App.tsx`
- Create: `src-tauri/tauri.conf.json`
**Step 1: Write the failing test**
Create a Rust compile smoke test for command registration entry.
```rust
// src-tauri/src/commands_tests.rs
#[test]
fn test_register_commands_compiles() {
assert!(true);
}
```
**Step 2: Run it to verify it fails**
Run: `cd /Users/zhixian/Codes/clawpal/src-tauri && cargo test --test commands_tests -- --nocapture`
Expected: FAIL with missing crate/modules (project尚未初始化).
**Step 3: Write the minimal implementation**
- Initialize a valid Tauri app with `invoke_handler` placeholder.
- Register placeholder command stubs returning `Ok(())`.
**Step 4: Run test to verify it passes**
Run: `cd /Users/zhixian/Codes/clawpal/src-tauri && cargo test --test commands_tests -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
cd /Users/zhixian/Codes/clawpal
git add package.json src-tauri/Cargo.toml src-tauri/src/main.rs src-tauri/src/lib.rs src-tauri/src/commands.rs src-tauri/src/commands_tests.rs src-tauri/tauri.conf.json src/main.tsx src/App.tsx
git commit -m "chore: scaffold Tauri + React MVP project skeleton"
```
### Task 2: 建立类型与路径模型
**Files:**
- Create: `src-tauri/src/models.rs`
- Create: `src-tauri/src/config_io.rs`
- Modify: `src-tauri/src/lib.rs`
- Create: `src-tauri/src/models_tests.rs`
**Step 1: Write the failing test**
```rust
#[test]
fn test_openclaw_paths_are_resolved() {
let paths = resolve_openclaw_paths();
assert!(paths.config_path.to_string_lossy().contains(".openclaw"));
assert!(paths.history_dir.ends_with(".clawpal/history"));
}
```
**Step 2: Run it to verify it fails**
Run: `cargo test models_tests::test_openclaw_paths_are_resolved`
Expected: FAIL (path resolver未实现 / test compile fail).
**Step 3: Write the minimal implementation**
- Define `OpenClawPaths { config_path, history_dir, metadata_path }`.
- Implement path resolution from home directory via `dirs` crate and create dirs if missing (non-destructive).
**Step 4: Run test to verify it passes**
Run: `cargo test models_tests::test_openclaw_paths_are_resolved`
Expected: PASS.
**Step 5: Commit**
```bash
git add src-tauri/src/models.rs src-tauri/src/config_io.rs src-tauri/src/models_tests.rs src-tauri/src/lib.rs
git commit -m "feat: define core path model for openclaw config and history"
```
### Task 3: 实现 Recipe 类型与参数校验
**Files:**
- Create: `src-tauri/src/recipe.rs`
- Modify: `src-tauri/src/commands.rs`
- Create: `src/lib/recipe_catalog.ts`
- Create: `src-tauri/src/recipe_tests.rs`
**Step 1: Write the failing test**
```rust
#[test]
fn test_validate_recipe_params_missing_required() {
let recipe = sample_recipe();
let params = serde_json::json!({});
assert!(validate_recipe_params(&recipe, &params).is_err());
}
```
**Step 2: Run it to verify it fails**
Run: `cargo test recipe_tests::test_validate_recipe_params_missing_required`
Expected: FAIL (validate function未实现).
**Step 3: Write the minimal implementation**
- Implement `Recipe`, `RecipeParam`, `RecipeEngine::validate`.
- Parse params from static TS catalog entry and pass through Tauri command `list_recipes`.
**Step 4: Run test to verify it passes**
Run: `cargo test recipe_tests::test_validate_recipe_params_missing_required`
Expected: PASS.
**Step 5: Commit**
```bash
git add src-tauri/src/recipe.rs src-tauri/src/recipe_tests.rs src-tauri/src/commands.rs src/lib/recipe_catalog.ts
git commit -m "feat: define recipe schema and parameter validation"
```
### Task 4: 实现预览与应用流程(含备份)
**Files:**
- Modify: `src-tauri/src/recipe.rs`
- Modify: `src-tauri/src/config_io.rs`
- Create: `src-tauri/src/history.rs`
- Modify: `src-tauri/src/commands.rs`
- Create: `src-tauri/src/recipe_flow_tests.rs`
- Modify: `src/lib/api.ts`
- Create: `src/lib/types.ts`
**Step 1: Write the failing test**
```rust
#[test]
fn test_apply_writes_backup_before_modify() {
// Arrange temp openclaw path & in-memory json
// Assert backup exists after apply with different content hash
}
```
**Step 2: Run it to verify it fails**
Run: `cargo test recipe_flow_tests::test_apply_writes_backup_before_modify`
Expected: FAIL (apply + history not implemented).
**Step 3: Write the minimal implementation**
- Implement preview by rendering template + merge patch to candidate JSON.
- On apply: read current config, create snapshot copy, write temporary file then atomically replace target.
- Emit `ApplyResult` with `snapshot_id` and backup path.
**Step 4: Run test to verify it passes**
Run: `cargo test recipe_flow_tests::test_apply_writes_backup_before_modify`
Expected: PASS.
**Step 5: Commit**
```bash
git add src-tauri/src/recipe.rs src-tauri/src/config_io.rs src-tauri/src/history.rs src-tauri/src/commands.rs src-tauri/src/recipe_flow_tests.rs src/lib/api.ts src/lib/types.ts
git commit -m "feat: implement recipe preview/apply with backup snapshot"
```
### Task 5: 实现历史列表和回滚命令
**Files:**
- Modify: `src-tauri/src/history.rs`
- Modify: `src-tauri/src/commands.rs`
- Create: `src-tauri/src/history_tests.rs`
- Create: `src/pages/History.tsx`
- Modify: `src/App.tsx`
**Step 1: Write the failing test**
```rust
#[test]
fn test_history_snapshot_roundtrip_rollback() {
// create fake metadata + two snapshots
// restore earlier snapshot and verify current differs then matches target
}
```
**Step 2: Run it to verify it fails**
Run: `cargo test history_tests::test_history_snapshot_roundtrip_rollback`
Expected: FAIL (history index and rollback 未实现).
**Step 3: Write the minimal implementation**
- Implement metadata read/write format with monotonically sorted IDs.
- Implement `list_history`, `preview_rollback`, and `rollback`.
- Add history UI with “查看 diff / 回滚” actions.
**Step 4: Run test to verify it passes**
Run: `cargo test history_tests::test_history_snapshot_roundtrip_rollback`
Expected: PASS.
**Step 5: Commit**
```bash
git add src-tauri/src/history.rs src-tauri/src/commands.rs src-tauri/src/history_tests.rs src/pages/History.tsx src/App.tsx
git commit -m "feat: add snapshot history list and rollback flow"
```
### Task 6: 实现 Doctor 检查与修复
**Files:**
- Create: `src-tauri/src/doctor.rs`
- Modify: `src-tauri/src/commands.rs`
- Create: `src-tauri/src/doctor_tests.rs`
- Create: `src/pages/Doctor.tsx`
- Modify: `src/lib/api.ts`
**Step 1: Write the failing test**
```rust
#[test]
fn test_doctor_catches_invalid_json_syntax() {
// Provide broken json5 content
// assert issue with code = json.syntax
}
```
**Step 2: Run it to verify it fails**
Run: `cargo test doctor_tests::test_doctor_catches_invalid_json_syntax`
Expected: FAIL (doctor module未实现).
**Step 3: Write the minimal implementation**
- Check parse validity, mandatory top-level fields, permission/ownership basics.
- Implement one-shot fix functions for trailing comma + missing required field default.
- Return actionable `DoctorReport` from command.
**Step 4: Run test to verify it passes**
Run: `cargo test doctor_tests::test_doctor_catches_invalid_json_syntax`
Expected: PASS.
**Step 5: Commit**
```bash
git add src-tauri/src/doctor.rs src-tauri/src/commands.rs src-tauri/src/doctor_tests.rs src/pages/Doctor.tsx src/lib/api.ts
git commit -m "feat: add doctor diagnostics and safe auto-fix hooks"
```
### Task 7: 前端安装向导与 Diff 页面
**Files:**
- Modify: `src/pages/Recipes.tsx`
- Create: `src/pages/Install.tsx`
- Create: `src/components/RecipeCard.tsx`
- Create: `src/components/ParamForm.tsx`
- Create: `src/components/DiffViewer.tsx`
- Modify: `src/lib/api.ts`
- Create: `src/pages/Home.tsx`
**Step 1: Write the failing test**
```ts
// src/__tests__/InstallFlow.spec.ts
test('install flow shows preview and apply action', async () => {
render(<Install />);
// fill form -> click preview -> ensure diff rendered -> apply enabled
});
```
**Step 2: Run it to verify it fails**
Run: `npx vitest run src/__tests__/InstallFlow.spec.ts`
Expected: FAIL (页面未实现).
**Step 3: Write the minimal implementation**
- Wire form to Recipe params from catalog and invoke `preview_apply`.
- Add DiffViewer with highlight.
- Add confirm/apply button and success toasts.
**Step 4: Run test to verify it passes**
Run: `npx vitest run src/__tests__/InstallFlow.spec.ts`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/pages/Recipes.tsx src/pages/Install.tsx src/components/RecipeCard.tsx src/components/ParamForm.tsx src/components/DiffViewer.tsx src/lib/api.ts src/pages/Home.tsx src/__tests__/InstallFlow.spec.ts
git commit -m "feat: implement recipe install wizard with preview+apply"
```
### Task 8: 主页面路由与状态整合
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/lib/state.ts`
- Modify: `src/pages/Home.tsx`
- Modify: `src/pages/History.tsx`
- Create: `src/lib/state_tests.ts`
**Step 1: Write the failing test**
```ts
test('state loads recipes and history on boot', async () => {
// mock tauri commands, render App, assert key tiles are loaded
});
```
**Step 2: Run it to verify it fails**
Run: `npx vitest run src/lib/state_tests.ts`
Expected: FAIL (state orchestration未实现).
**Step 3: Write the minimal implementation**
- Implement shared reducer/events for system status, recipes, history, doctor report.
- Add tabs/routes for Home/Recipes/History/Doctor.
**Step 4: Run test to verify it passes**
Run: `npx vitest run src/lib/state_tests.ts`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/App.tsx src/lib/state.ts src/lib/state_tests.ts src/pages/Home.tsx src/pages/History.tsx
git commit -m "feat: wire app-wide state and navigation"
```
### Task 9: 端到端验收与发布脚手架
**Files:**
- Modify: `README.md`
- Modify: `package.json`
- Create: `scripts/release.sh`
- Create: `docs/mvp-checklist.md`
- Modify: `src-tauri/tauri.conf.json`
**Step 1: Write the failing test**
```bash
./scripts/release.sh --dry-run
```
Expected: FAIL (脚本不存在).
**Step 2: Run it to verify it fails**
Run: `bash scripts/release.sh --dry-run`
Expected: FAIL / script not found.
**Step 3: Write the minimal implementation**
- Add build scripts and basic build verification matrix (macOS/Windows/Linux placeholders).
- Add manual QA checklist to validate install/rollback/doctor on clean and broken states.
- Update README for usage.
**Step 4: Run test to verify it passes**
Run: `bash scripts/release.sh --dry-run`
Expected: PASS (dry-run prints validated commands without publishing).
**Step 5: Commit**
```bash
git add README.md package.json scripts/release.sh docs/mvp-checklist.md src-tauri/tauri.conf.json
git commit -m "chore: add MVP runbook and release scripts"
```
---
## 执行方式
Plan complete and saved to `docs/plans/2026-02-15-clawpal-mvp-implementation-plan.md`. Two execution options:
1. Subagent-Driven (this session) - 我在当前会话按任务分派并在每步进行复核
2. Parallel Session (separate) - 开新会话并使用 executing-plans 执行,有里程碑检查点
Which approach?

View File

@@ -0,0 +1,260 @@
# ClawPal 产品精简 & 重新定位
> 从"全功能配置管理后台"回归"AI 配置助手"
## 1. 问题
v0.2 新增了 Models、Channels、Data 三个管理页面后,产品从"场景驱动的配置助手"滑向了"OpenClaw 全功能管理后台"。功能杂糅,偏离了核心用户(新手)的需求。
## 2. 新产品定位
**ClawPal = AI 配置助手 + 精选 Recipe 库**
- 核心用户:不想碰 JSON 的新手
- 第一入口Chat — 用自然语言描述需求LLM 生成配置方案
- 第二入口Recipe — 社区精选的常见场景,一键安装
- 安全保证所有变更Chat 或 Recipe走同一安全链路快照 → 预览 diff → 用户确认 → 应用
## 3. 精简前后对比
| 精简前 (7 页面) | 精简后 | 处理方式 |
|---|---|---|
| Home (状态仪表盘) | Home (状态 + Agents + 推荐 + Chat) | 重构 |
| Recipes | Recipes | 保留 |
| Install | Install (从 Recipe/Chat 进入) | 保留 |
| History | History | 保留 |
| Doctor | Doctor (+ 数据清理) | 扩展 |
| Models | Settings > Model Profiles | 降级,移除绑定功能 |
| Channels | 删除 | 由 Chat/Recipe 覆盖 |
| Data | 合并入 Doctor | 删除独立页面 |
## 4. 导航结构
左侧边栏4 个主入口 + 1 个设置:
```
┌──────────┬──────────────────────────────────┐
│ │ │
│ Home │ │
│ │ │
│ Recipes │ 页面内容 │
│ │ │
│ History │ │
│ │ │
│ Doctor │ │
│ │ │
│ ─────── │ │
│ Settings│ │
│ │ │
└──────────┴──────────────────────────────────┘
```
Install 不在侧边栏出现,从 Recipe 卡片或 Chat 触发后进入。
## 5. Home 页面
左右分栏布局:左侧主内容区,右侧常驻 Chat 窗口。
### 5.1 左侧主区域(四段式)
**状态摘要** — 一行紧凑卡片:
- 配置健康状态(✅/❌)
- OpenClaw 版本 + 是否有更新
- 当前默认模型
**Agents 概览** — 每个 Agent 一行:
- Agent 名称 / ID
- 当前使用的模型
- 关联的 ChannelsDiscord#xxx、Telegram 等)
- 在线状态
**推荐 Recipes** — 3-4 张卡片,点击进入安装向导
**最近操作** — 最近 3-5 条历史记录
### 5.2 右侧 Chat 窗口
常驻面板,使用用户在 Settings 中配置的 Model Profile 调用 LLM。
**LLM 可调用的工具集:**
| 工具 | 作用 | 安全级别 |
|------|------|----------|
| `read_config` | 读取当前配置 | 无风险(只读) |
| `list_agents` | 列出 Agent 信息 | 无风险 |
| `list_recipes` | 搜索/推荐 Recipe | 无风险 |
| `preview_change` | 生成配置补丁并展示 diff | 无风险(不写入) |
| `apply_change` | 应用配置变更 | 需用户点确认 |
| `run_doctor` | 运行诊断 | 无风险 |
| `generate_recipe` | 根据描述生成新 Recipe | 无风险(只生成定义) |
**交互流程:**
```
用户: "我想让 agent-2 在 Telegram 群里只回复被 @ 的消息"
LLM: 调用 read_config 了解当前配置
LLM: 生成配置补丁,调用 preview_change
Chat 内展示 diff 面板 + [确认应用] [取消] 按钮
用户点确认 → 自动快照 → 写入配置
```
**关键约束:**
- 所有写操作必须先展示 diff用户手动确认
- 使用用户在 Settings 中选定的 Chat 模型
- 未配置 Model Profile 时Chat 区域提示引导去 Settings
### 5.3 布局示意
```
┌──────────┬──────────────────────────┬──────────────────┐
│ │ │ │
│ Sidebar │ 状态摘要 (一行卡片) │ Chat 窗口 │
│ │ ┌────┐ ┌────┐ ┌────┐ │ │
│ │ │健康 │ │版本 │ │模型 │ │ 💬 你想实现 │
│ │ │ ✅ │ │2.13│ │gpt4│ │ 什么配置? │
│ │ └────┘ └────┘ └────┘ │ │
│ │ │ ┌────────────┐ │
│ │ Agents │ │ 输入框 │ │
│ │ ┌──────────────────────┐ │ └────────────┘ │
│ │ │ agent-1 gpt-4o │ │ │
│ │ │ ✅ online Discord#1│ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ agent-2 claude │ │ │
│ │ │ ✅ online Telegram │ │ │
│ │ ├──────────────────────┤ │ │
│ │ │ agent-3 gpt-4o │ │ │
│ │ │ ⚠ no channel │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │
│ │ 推荐 Recipes │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐│ │
│ │ │人设 │ │模型 │ │性能 ││ │
│ │ └─────┘ └─────┘ └─────┘│ │
│ │ │ │
│ │ 最近操作 │ │
│ │ • 2/15 应用了"Discord人设"│ │
│ │ • 2/14 运行了 Doctor │ │
│ │ │ │
└──────────┴──────────────────────────┴──────────────────┘
```
## 6. Recipes 页面
保持现有设计:卡片式浏览,支持搜索/筛选/标签。
新增的 Recipe 类型(替代被删除的页面功能):
- "切换默认模型" — 替代 Models 页面的全局绑定
- "为 Agent 设置专属模型" — 替代 Models 页面的 Agent 绑定
- "配置 Discord 频道白名单" — 替代 Channels 页面
- "配置 Telegram mention 规则" — 替代 Channels 页面
## 7. Doctor 页面(扩展)
两个区域:
### 7.1 配置诊断(原有)
- JSON 语法检查
- 必填字段验证
- 端口占用检测
- 文件权限检查
- 一键修复 + 变更原因展示
### 7.2 数据清理(从 Data 合并)
- Memory 文件统计 + 一键清理
- Session 文件统计 + 按 Agent 清理 / 全部清理
- 磁盘占用展示
```
┌─────────────────────────────────────────┐
│ Doctor │
│ │
│ 配置诊断 [运行检查] │
│ ┌─────────────────────────────────┐ │
│ │ ✅ JSON 语法正确 │ │
│ │ ✅ 必填字段完整 │ │
│ │ ❌ 端口 8080 被占用 │ │
│ │ → [一键修复] 切换到 8081 │ │
│ └─────────────────────────────────┘ │
│ │
│ 数据清理 │
│ ┌─────────────────────────────────┐ │
│ │ Memory: 6 files (2.3 MB) │ │
│ │ [清理全部] │ │
│ │ Sessions: 23 files (15.1 MB) │ │
│ │ agent-1: 12 files (8.2 MB) │ │
│ │ agent-2: 11 files (6.9 MB) │ │
│ │ [按 Agent 清理] [全部] │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## 8. Settings 页面
### 8.1 Model Profiles
首次启动自动从 OpenClaw 配置中提取,无需手动操作。
Profile 卡片展示字段:
- 名称
- 服务商provider
- 模型model
- API Key脱敏`sk-proj-abc...7xZ`
- 自定义地址(仅设置时显示)
```
┌─────────────────────────────────────┐
│ GPT-4o ✅ 启用 │
│ 服务商: openai │
│ 模型: gpt-4o │
│ API Key: sk-proj-abc...7xZ │
│ [编辑] [删除] │
├─────────────────────────────────────┤
│ Claude ✅ 启用 │
│ 服务商: anthropic │
│ 模型: claude-sonnet-4-5 │
│ API Key: sk-ant-k03...mNp │
│ 自定义地址: https://my-proxy.com/v1 │
│ [编辑] [删除] │
└─────────────────────────────────────┘
│ [+ 新建 Profile] │
```
### 8.2 Chat 模型选择
下拉选择 Chat 窗口使用的 Model Profile。
### 8.3 路径配置
OpenClaw 目录和 ClawPal 数据目录的显示与自定义。
## 9. 删除清单
以下代码在实施时需要删除或重构:
| 文件 | 处理 |
|------|------|
| `src/pages/Channels.tsx` | 删除 |
| `src/pages/Data.tsx` | 删除,功能迁移到 Doctor |
| `src/pages/Models.tsx` | 删除Profile 管理迁移到 Settings |
| `src-tauri/src/commands.rs` | 移除 Channel CRUD、Data 相关命令,保留 Model Profile 命令 |
| `src/App.tsx` | 移除 Channels/Data/Models 路由,改为侧边栏布局 |
## 10. 新增开发项
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 侧边栏布局 | P0 | 替换当前顶部 Tab |
| Home 重构 | P0 | 四段式 + 右侧 Chat |
| Chat 窗口 | P0 | LLM 工具调用 + diff 确认流程 |
| Settings 页面 | P0 | Model Profile 管理 + Chat 模型选择 |
| Doctor 扩展 | P1 | 合并数据清理功能 |
| 新 Recipes | P1 | 模型切换、频道配置等替代 Recipe |
| Agents 概览 API | P1 | 首页 Agent 列表的后端支持 |
| 首次启动自动提取 | P2 | 自动从配置提取 Model Profiles |
---
*Created: 2026-02-16*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
# Model & Channel Management Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add practical in-app management for model auth profiles, model binding strategy, and basic channel configuration (dm/group/allowlist/mode) with safe write operations and Home status visibility.
**Architecture:** Keep existing Tauri command boundary as the source of truth. Rust keeps all file I/O + patch writes; UI only renders/edit values and submits commands. Configuration changes go through atomic write + snapshot, while model auth profile registry lives in ClawPal metadata directory for separation from OpenClaw config.
**Tech Stack:** Tauri 2, Rust (`serde`, `serde_json`), React 18, TypeScript.
---
### Task 1: Define shared data contract for model/channels
**Files:**
- `src/lib/types.ts`
- `src-tauri/src/commands.rs`
**Step 1: Write the structure in types + command models (code first pass)**
- Add frontend interfaces for:
- `ModelProfile` (`id`, `name`, `provider`, `model`, `authRef`, `baseUrl`, `description`, `enabled`)
- `ChannelNode` (`path`, `channelType`, `mode`, `allowlist`, `modelValue`, `hasModelField`)
- `ModelBinding` (`scope`, `scopeId`, `modelProfileId`, `modelValue`)
- API command input/output types.
**Step 2: Add corresponding Rust serializable structs in `commands.rs`**
- Add `#[derive(Serialize, Deserialize)]` structs that mirror above.
---
### Task 2: Backing storage + utilities
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/models.rs`
**Step 1: Add metadata paths for profile storage**
- Use `OpenClawPaths` with existing `clawpal_dir` and introduce a `model_profiles.json` file path inside it.
**Step 2: Implement helper readers/writers**
- `load_model_profiles(paths) -> Vec<ModelProfile>`
- `save_model_profiles(paths, &Vec<ModelProfile>)`
- Path-safe setters for nested JSON fields inside `openclaw.json` using dot-path nodes.
---
### Task 3: Model profile CRUD commands
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src/lib/api.ts`
- `src/lib/types.ts`
**Step 1: Add Tauri commands**
- `list_model_profiles() -> Vec<ModelProfile>`
- `upsert_model_profile(profile: ModelProfile) -> ModelProfile`
- `delete_model_profile(profile_id: String) -> bool`
**Step 2: Register commands in `invoke_handler`**
- Ensure frontend can call these commands via API wrapper.
---
### Task 4: Channel discovery and channel config commands
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src/lib/api.ts`
- `src/lib/types.ts`
**Step 1: Discover editable channel nodes**
- Implement recursion under `/channels` for nodes that carry `type|mode|allowlist|model`.
- Return normalized `ChannelNode` list with stable paths.
**Step 2: Expose write commands**
- `list_channels() -> Vec<ChannelNode>`
- `update_channel_config(path: String, channel_type: Option<String>, mode: Option<String>, allowlist: Vec<String>, model: Option<String>) -> bool`
- `delete_channel_node(path: String) -> bool` (safe: remove node if exists)
**Step 3: Register commands**
- Add new invoke entries in `lib.rs`.
---
### Task 5: Model binding commands (global/agent/channel)
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src/lib/api.ts`
- `src/lib/types.ts`
**Step 1: Implement read summary**
- Add helper to return effective model binding map from current config:
- global default model
- per-agent model overrides
- per-channel model overrides
**Step 2: Implement commands**
- `set_global_model(profile_id: Option<String>) -> bool`
- `set_agent_model(agent_id: String, profile_id: Option<String>) -> bool`
- `set_channel_model(channel_path: String, profile_id: Option<String>) -> bool`
**Step 3: Snapshot safety**
- Use existing snapshot flow (`read_openclaw_config` + `add_snapshot`) before each write.
---
### Task 6: Frontend pages and routing
**Files:**
- `src/App.tsx`
- `src/pages/Models.tsx` (new)
- `src/pages/Channels.tsx` (new)
- `src/lib/api.ts`
- `src/lib/types.ts`
**Step 1: Wire routes for Models and Channels**
- Extend top nav and render new pages.
**Step 2: Models page**
- List model profiles, create/update/delete quickly.
- Add `profile id` selector.
- Add global / agent / channel assignment panel (simple controls first).
**Step 3: Channels page**
- List discovered channel nodes and allow update model/allowlist/mode.
- Add minimal inline editing (textarea for allowlist lines).
---
### Task 7: Surface in Home
**Files:**
- `src-tauri/src/commands.rs`
- `src/lib/types.ts`
- `src/pages/Home.tsx`
**Step 1: Expand system status with `model` binding counts**
- Keep current aggregates and make counts update from registry + bindings.
**Step 2: Display quick links/button actions**
- From Home open Models / Channels quick nav (simple text action).
---
### Task 8: Manual verification checklist
**Files:**
- `docs/mvp-checklist.md`
**Step 1: Add verification items for this feature set**
- Profile add/update/delete.
- Channel mode/allowlist/model update.
- Global/agent/channel model assignment.
- Status card updates after edits.
**Step 2: Execute manual run list**
- Build frontend and verify command invocation completes.

View File

@@ -0,0 +1,109 @@
# Openclaw-Driven Model/Channel Ops Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
## For Feature
**Goal:** Enable model catalog and model/profile management to use openclaw CLI + caching keyed to openclaw version, add upgrade-check visibility, sync current config models into profiles, and enrich discord channel metadata.
**Architecture:** Introduce a small openclaw-command layer in Tauri that executes `openclaw` with `--json` options, normalizes JSON outputs, writes cache under clawpal dir keyed by openclaw CLI version, and exposes UI actions for sync/inspect.
**Tech Stack:** Rust (`std::process` + `serde_json`), React/Tauri IPC, OpenClaw CLI commands.
---
### Task 1: OpenClaw command adapter + version-aware model catalog cache
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/models.rs`
- `src-tauri/Cargo.toml` (if needed)
**Step 1: Implement command runner utilities**
- Add small wrappers for running CLI commands (`openclaw --version`, `openclaw models list --all --json`, with optional `--provider`, `openclaw channels list --json --no-usage`, `openclaw update status --json`, `openclaw channels resolve ... --json` when needed).
- Parse and normalize JSON outputs into internal structs.
- Capture stderr for diagnostic messages.
**Step 2: Add cache structs and paths**
- Add catalog cache file path e.g. `model-catalog-cache.json` under clawpal dir.
- Include fields: `cliVersion`, `updatedAt`, `providers`, `source`, `error`, `ttlMinutes`.
**Step 3: Add version-aware refresh policy**
- Read current `openclaw --version` and current cache version.
- If version changed, or cache stale (e.g. 12h), refresh by running `openclaw models list --all --json`.
- If refresh fails, fall back to config-based extraction so UI remains usable.
**Step 4: Wire `list_model_catalog` to new pipeline**
- Return CLI/ cache-normalized `ModelCatalogProvider[]`.
- Keep `model/probe` fallback from config providers in emergency path.
### Task 2: OpenClaw upgrade status endpoint
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src/lib/types.ts`
- `src/lib/api.ts`
- `src/pages/Home.tsx`
**Step 1: Add API result model**
- Add `OpenclawUpdateCheck` to `types.ts` with `installKind/channel/source/version/availability` etc (at least `outdated`, `currentVersion`, `latestVersion`, `upgradeAvailable`, `updateLine`, `checkedAt`).
**Step 2: Add backend command**
- Add `check_openclaw_update` that runs `openclaw update status --json`.
- Return normalized result and errors non-fatal to UI.
**Step 3: Extend status payload**
- Optionally add `openclawUpdate` field to `SystemStatus`, fetched inside `get_system_status`.
**Step 4: Show on Home page**
- Add “OpenClaw update” card: current vs latest, and availability warning if newer exists.
### Task 3: Auto-extract existing model bindings to profiles
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src/lib/types.ts`
- `src/lib/api.ts`
- `src/pages/Models.tsx`
**Step 1: Add extractor command**
- New command `extract_model_profiles_from_config(dryRun: bool)` that inspects `/agents/*model`, `channels.*.model`, and defaults for explicit model refs.
- For each unique model ref create/refresh profiles from current config, using auth mapping heuristics:
- if model has `provider` prefix and `auth` map exists, pick first matching `auth.profiles` for that provider;
- fallback to `default`.
- Return `created|updated|skipped` stats and preview list.
**Step 2: Frontend button action**
- Add button `Import current model config as profiles` in models page.
- Add optional checkbox / confirm to enable overwrite for detected matches.
**Step 3: Make bindings discover profile matches**
- Since extractor writes profiles with exact `provider/model`, channel/agent bindings immediately resolve to profile IDs.
- Keep `find_profile_by_model` logic unchanged (already matches by exact string).
### Task 4: Discord channel name enrichment via CLI resolve
**Files:**
- `src-tauri/src/commands.rs`
- `src-tauri/src/lib.rs`
- `src-tauri/src/types.ts` (if new shape)
- `src/pages/Channels.tsx`
**Step 1: Add channel-name resolver command**
- Parse Discord channel IDs from channel node paths (e.g. `channels.discord.guilds.<guildId>.channels.<channelId>`).
- For discovered IDs, call `openclaw channels resolve <id> --channel discord --kind group --json` and map id->name.
- Return best effort, with `displayName` and `nameStatus` (`resolved`/`missing`).
**Step 2: Cache small resolution results**
- Add small cache in clawpal dir keyed by `guildId:channelId` and openclaw version.
- Graceful fallback to raw ID when resolve fails.
**Step 3: UI display**
- Add `displayName` in channel row and keep path as stable key.
### Execution flow
**Use subagent-driven mode with review checkpoints** because this spans Rust + React + IPC.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClawPal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1742
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "clawpal",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"release:dry-run": "bash scripts/release.sh --dry-run",
"release": "bash scripts/release.sh"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"json5": "^2.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.5.4",
"vite": "^5.4.1"
}
}

30
scripts/release.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
say() {
printf "%s\n" "$1"
}
run_or_print() {
if [ "$DRY_RUN" -eq 1 ]; then
say "[dry-run] $*"
else
say "[run] $*"
eval "$@"
fi
}
say "ClawPal MVP release assistant"
run_or_print "npm run typecheck"
run_or_print "npm run build"
run_or_print "cd src-tauri && cargo fmt --all --check"
run_or_print "cd src-tauri && cargo check"
run_or_print "cd src-tauri && cargo check --target-dir target/check"
run_or_print "cd src-tauri && cargo check"
run_or_print "cd src-tauri && cargo tauri build"
say "Done."

5255
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "clawpal"
version = "0.1.0"
edition = "2021"
[lib]
name = "clawpal"
crate-type = ["staticlib", "cdylib", "rlib"]
[dependencies]
dirs = "5.0.1"
json5 = "0.4.1"
regex = "1.10.6"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0.214", features = ["derive"] }
serde_json = "1.0.133"
tauri = { version = "2.1.0", features = [] }
thiserror = "1.0.63"
uuid = { version = "1.11.0", features = ["v4"] }
chrono = { version = "0.4.38", features = ["clock"] }
[build-dependencies]
tauri-build = { version = "2.1.0", features = [] }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

66
src-tauri/recipes.json Normal file
View File

@@ -0,0 +1,66 @@
{
"recipes": [
{
"id": "discord-channel-persona",
"name": "Discord channel persona",
"description": "Inject different system prompt for one Discord channel",
"version": "1.0.0",
"tags": ["discord", "persona", "beginner"],
"difficulty": "easy",
"params": [
{
"id": "guild_id",
"label": "Guild ID",
"type": "string",
"required": true,
"pattern": "^[0-9]+$",
"minLength": 17,
"maxLength": 20,
"placeholder": "Copy guild id"
},
{
"id": "channel_id",
"label": "Channel ID",
"type": "string",
"required": true,
"pattern": "^[0-9]+$",
"minLength": 17,
"maxLength": 20,
"placeholder": "Copy channel id"
},
{
"id": "persona",
"label": "Persona",
"type": "textarea",
"required": true,
"minLength": 1,
"placeholder": "You are..."
}
],
"patchTemplate": "\n{\n \"channels\": {\n \"discord\": {\n \"guilds\": {\n \"{{guild_id}}\": {\n \"channels\": {\n \"{{channel_id}}\": {\n \"systemPrompt\": \"{{persona}}\"\n }\n }\n }\n }\n }\n }\n}",
"impactCategory": "low",
"impactSummary": "Add/modify channel persona"
},
{
"id": "model-switch",
"name": "Model switch",
"description": "Quickly switch default model",
"version": "1.0.0",
"tags": ["model", "productivity"],
"difficulty": "easy",
"params": [
{
"id": "model_name",
"label": "Model name",
"type": "string",
"required": true,
"minLength": 1,
"placeholder": "gpt-4o"
}
],
"patchTemplate": "\n{\n \"agents\": {\n \"defaults\": {\n \"model\": \"{{model_name}}\"\n }\n }\n}",
"impactCategory": "low",
"impactSummary": "Switch default model"
}
]
}

2704
src-tauri/src/commands.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::models::OpenClawPaths;
pub const DEFAULT_CONFIG: &str = r#"{}"#;
pub fn ensure_dirs(paths: &OpenClawPaths) -> Result<(), String> {
fs::create_dir_all(&paths.base_dir).map_err(|e| e.to_string())?;
fs::create_dir_all(&paths.history_dir).map_err(|e| e.to_string())?;
Ok(())
}
pub fn read_text(path: &Path) -> Result<String, String> {
if !path.exists() {
return Ok(DEFAULT_CONFIG.to_string());
}
let mut file = File::open(path).map_err(|e| e.to_string())?;
let mut content = String::new();
file.read_to_string(&mut content).map_err(|e| e.to_string())?;
Ok(content)
}
pub fn write_text(path: &Path, content: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let tmp = path.with_extension("tmp");
{
let mut file = File::create(&tmp).map_err(|e| e.to_string())?;
file.write_all(content.as_bytes()).map_err(|e| e.to_string())?;
file.sync_all().map_err(|e| e.to_string())?;
}
fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(())
}
pub fn read_json<T>(path: &Path) -> Result<T, String>
where
T: DeserializeOwned,
{
let text = read_text(path)?;
let parsed = json5::from_str::<T>(&text).map_err(|e| e.to_string())?;
Ok(parsed)
}
pub fn write_json<T>(path: &Path, value: &T) -> Result<(), String>
where
T: Serialize,
{
let pretty = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
write_text(path, &pretty)
}
pub fn read_openclaw_config(paths: &OpenClawPaths) -> Result<Value, String> {
ensure_dirs(paths)?;
read_json::<Value>(&paths.config_path).or_else(|_| Ok(Value::Object(Default::default())))
}

148
src-tauri/src/doctor.rs Normal file
View File

@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config_io::read_openclaw_config;
use crate::models::OpenClawPaths;
use regex::Regex;
#[derive(Debug, Serialize, Deserialize)]
pub struct DoctorIssue {
pub id: String,
pub code: String,
pub severity: String,
pub message: String,
pub auto_fixable: bool,
pub fix_hint: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DoctorReport {
pub ok: bool,
pub score: u8,
pub issues: Vec<DoctorIssue>,
}
pub fn apply_auto_fixes(paths: &OpenClawPaths, issue_ids: &[String]) -> Vec<String> {
let text = std::fs::read_to_string(&paths.config_path).unwrap_or_else(|_| "{}".into());
let mut current = match json5::from_str::<Value>(&text) {
Ok(v) => v,
Err(_) => Value::Object(Default::default()),
};
let mut fixed = Vec::new();
if issue_ids.iter().any(|id| id == "field.agents") && current.get("agents").is_none() {
let mut agents = serde_json::Map::new();
let mut defaults = serde_json::Map::new();
defaults.insert("model".into(), Value::String("gpt-4o".into()));
agents.insert("defaults".into(), Value::Object(defaults));
if let Value::Object(map) = &mut current {
map.insert("agents".into(), Value::Object(agents));
}
fixed.push("field.agents".into());
}
if issue_ids.iter().any(|id| id == "json.syntax") {
if current.is_null() {
if let Ok(safe) = json5::from_str::<Value>("{\"agents\":{\"defaults\":{\"model\":\"gpt-4o\"}}}") {
current = safe;
fixed.push("json.syntax".into());
}
}
}
if issue_ids.iter().any(|id| id == "field.port") {
let mut gateway = current
.get("gateway")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
gateway.insert("port".into(), Value::Number(serde_json::Number::from(8080_u64)));
if let Value::Object(map) = &mut current {
map.insert("gateway".into(), Value::Object(gateway));
}
fixed.push("field.port".into());
}
let maybe_json = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
if !fixed.is_empty() {
let _ = clean_and_write_json(paths, &maybe_json);
}
fixed
}
fn clean_and_write_json(paths: &OpenClawPaths, text: &str) -> Result<(), String> {
let trailing = Regex::new(r",(\s*[}\]])").map_err(|e| e.to_string())?;
let normalized = trailing.replace_all(text, "$1");
crate::config_io::write_text(&paths.config_path, normalized.as_ref())
}
pub fn run_doctor(paths: &OpenClawPaths) -> DoctorReport {
let mut issues = Vec::new();
let mut score: i32 = 100;
let text = std::fs::read_to_string(&paths.config_path).unwrap_or_else(|_| "{}".into());
if json5::from_str::<Value>(&text).is_err() {
issues.push(DoctorIssue {
id: "json.syntax".into(),
code: "json.syntax".into(),
severity: "error".into(),
message: "Invalid JSON5 syntax".into(),
auto_fixable: true,
fix_hint: Some("Try removing trailing commas and unmatched quotes".into()),
});
score -= 40;
}
if let Ok(cfg) = read_openclaw_config(paths) {
if cfg.get("agents").is_none() {
issues.push(DoctorIssue {
id: "field.agents".into(),
code: "required.field".into(),
severity: "warn".into(),
message: "Missing agents field; recommend initializing defaults".into(),
auto_fixable: true,
fix_hint: Some("Add agents.defaults with safe minimal values".into()),
});
score -= 10;
}
if let Some(port) = cfg.pointer("/gateway/port").and_then(|v| v.as_u64()) {
if port > 65535 {
issues.push(DoctorIssue {
id: "field.port".into(),
code: "invalid.port".into(),
severity: "error".into(),
message: "Gateway port is invalid".into(),
auto_fixable: false,
fix_hint: None,
});
score -= 20;
}
}
}
let perms_ok = paths.config_path.exists()
&& std::fs::metadata(&paths.config_path)
.map(|m| !m.permissions().readonly())
.unwrap_or(false);
if !perms_ok {
issues.push(DoctorIssue {
id: "permission.config".into(),
code: "fs.permission".into(),
severity: "error".into(),
message: "Config file is readonly or inaccessible".into(),
auto_fixable: false,
fix_hint: Some("Grant write permission then retry".into()),
});
score -= 20;
}
let mut unique = std::collections::HashSet::new();
issues.retain(|issue| unique.insert(issue.id.clone()));
DoctorReport {
ok: score >= 80,
score: score.max(0) as u8,
issues,
}
}

90
src-tauri/src/history.rs Normal file
View File

@@ -0,0 +1,90 @@
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use chrono::Utc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SnapshotMeta {
pub id: String,
pub recipe_id: Option<String>,
pub created_at: String,
pub config_path: String,
pub source: String,
pub can_rollback: bool,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct SnapshotIndex {
pub items: Vec<SnapshotMeta>,
}
pub fn list_snapshots(path: &std::path::Path) -> Result<SnapshotIndex, String> {
if !path.exists() {
return Ok(SnapshotIndex { items: Vec::new() });
}
let mut file = File::open(path).map_err(|e| e.to_string())?;
let mut text = String::new();
file.read_to_string(&mut text).map_err(|e| e.to_string())?;
if text.trim().is_empty() {
return Ok(SnapshotIndex { items: Vec::new() });
}
serde_json::from_str(&text).map_err(|e| e.to_string())
}
pub fn write_snapshots(path: &std::path::Path, index: &SnapshotIndex) -> Result<(), String> {
let parent = path.parent().ok_or_else(|| "invalid metadata path".to_string())?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut file = File::create(path).map_err(|e| e.to_string())?;
let text = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
file.write_all(text.as_bytes()).map_err(|e| e.to_string())
}
pub fn add_snapshot(
paths: &PathBuf,
metadata_path: &PathBuf,
recipe_id: Option<String>,
source: &str,
rollbackable: bool,
current_config: &str,
) -> Result<SnapshotMeta, String> {
fs::create_dir_all(paths).map_err(|e| e.to_string())?;
let index = list_snapshots(metadata_path).unwrap_or_default();
let ts = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let snapshot_recipe_id = recipe_id.clone().unwrap_or_else(|| "manual".into());
let id = format!("{}-{}", ts, snapshot_recipe_id);
let snapshot_path = paths.join(format!("{}.json", id.replace(':', "-")));
fs::write(&snapshot_path, current_config).map_err(|e| e.to_string())?;
let mut next = index;
next.items.push(SnapshotMeta {
id: id.clone(),
recipe_id,
created_at: ts.clone(),
config_path: snapshot_path.to_string_lossy().to_string(),
source: source.to_string(),
can_rollback: rollbackable,
});
next.items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
if next.items.len() > 200 {
next.items.truncate(200);
}
write_snapshots(metadata_path, &next)?;
let returned = Some(snapshot_recipe_id.clone());
Ok(SnapshotMeta {
id,
recipe_id: returned,
created_at: ts,
config_path: snapshot_path.to_string_lossy().to_string(),
source: source.to_string(),
can_rollback: rollbackable,
})
}
pub fn read_snapshot(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| e.to_string())
}

57
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,57 @@
use crate::commands::{
apply_recipe, fix_issues, get_system_status, get_status_light, list_history, list_recipes, preview_apply,
list_model_profiles, upsert_model_profile, delete_model_profile,
list_model_catalog, get_cached_model_catalog, refresh_model_catalog,
check_openclaw_update, extract_model_profiles_from_config,
list_agent_ids, list_agents_overview, list_memory_files, delete_memory_file, clear_memory, list_session_files,
delete_session_file, clear_all_sessions, clear_agent_sessions,
preview_rollback, rollback, run_doctor_command,
resolve_api_keys, read_raw_config, resolve_full_api_key, open_url, chat_via_openclaw,
};
pub mod commands;
pub mod config_io;
pub mod doctor;
pub mod history;
pub mod models;
pub mod recipe;
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_system_status,
get_status_light,
list_recipes,
list_model_profiles,
list_model_catalog,
get_cached_model_catalog,
refresh_model_catalog,
upsert_model_profile,
delete_model_profile,
list_agent_ids,
list_agents_overview,
list_memory_files,
delete_memory_file,
clear_memory,
list_session_files,
delete_session_file,
clear_all_sessions,
clear_agent_sessions,
check_openclaw_update,
extract_model_profiles_from_config,
preview_apply,
apply_recipe,
list_history,
preview_rollback,
rollback,
run_doctor_command,
fix_issues,
resolve_api_keys,
read_raw_config,
resolve_full_api_key,
open_url,
chat_via_openclaw,
])
.run(tauri::generate_context!())
.expect("failed to run app");
}

3
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
clawpal::run();
}

52
src-tauri/src/models.rs Normal file
View File

@@ -0,0 +1,52 @@
use std::env;
use std::path::{Path, PathBuf};
use dirs::home_dir;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawPaths {
pub openclaw_dir: PathBuf,
pub config_path: PathBuf,
pub base_dir: PathBuf,
pub clawpal_dir: PathBuf,
pub history_dir: PathBuf,
pub metadata_path: PathBuf,
}
fn expand_user_path(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/") {
if let Some(home) = home_dir() {
return home.join(rest);
}
}
PathBuf::from(raw)
}
fn env_path(name: &str) -> Option<PathBuf> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(|value| expand_user_path(&value))
}
pub fn resolve_paths() -> OpenClawPaths {
let home = home_dir().unwrap_or_else(|| Path::new(".").to_path_buf());
let openclaw_dir =
env_path("CLAWPAL_OPENCLAW_DIR").or_else(|| env_path("OPENCLAW_HOME")).unwrap_or_else(|| home.join(".openclaw"));
let clawpal_dir =
env_path("CLAWPAL_DATA_DIR").unwrap_or_else(|| openclaw_dir.join(".clawpal"));
let config_path = openclaw_dir.join("openclaw.json");
let history_dir = clawpal_dir.join("history");
let metadata_path = clawpal_dir.join("metadata.json");
OpenClawPaths {
openclaw_dir: openclaw_dir.clone(),
config_path,
base_dir: openclaw_dir.clone(),
clawpal_dir,
history_dir,
metadata_path,
}
}

305
src-tauri/src/recipe.rs Normal file
View File

@@ -0,0 +1,305 @@
use std::{env, fs, path::{Path, PathBuf}};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
const BUILTIN_RECIPES_JSON: &str = include_str!("../recipes.json");
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum RecipeDocument {
List(Vec<Recipe>),
Wrapped { recipes: Vec<Recipe> },
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecipeParam {
pub id: String,
pub label: String,
#[serde(rename = "type")]
pub kind: String,
pub required: bool,
pub pattern: Option<String>,
pub min_length: Option<usize>,
pub max_length: Option<usize>,
pub placeholder: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Recipe {
pub id: String,
pub name: String,
pub description: String,
pub version: String,
pub tags: Vec<String>,
pub difficulty: String,
pub params: Vec<RecipeParam>,
pub patch_template: String,
pub impact_category: String,
pub impact_summary: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChangeItem {
pub path: String,
pub op: String,
pub risk: String,
pub reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PreviewResult {
pub recipe_id: String,
pub diff: String,
pub changes: Vec<ChangeItem>,
pub overwrites_existing: bool,
pub can_rollback: bool,
pub impact_level: String,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApplyResult {
pub ok: bool,
pub snapshot_id: Option<String>,
pub config_path: String,
pub backup_path: Option<String>,
pub warnings: Vec<String>,
pub errors: Vec<String>,
}
pub fn builtin_recipes() -> Vec<Recipe> {
parse_recipes_document(BUILTIN_RECIPES_JSON).unwrap_or_else(|_| Vec::new())
}
fn is_http_url(candidate: &str) -> bool {
candidate.starts_with("http://") || candidate.starts_with("https://")
}
fn expand_user_path(candidate: &str) -> PathBuf {
if let Some(rest) = candidate.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(candidate)
}
fn parse_recipes_document(text: &str) -> Result<Vec<Recipe>, String> {
let document: RecipeDocument = json5::from_str(text).map_err(|e| e.to_string())?;
match document {
RecipeDocument::List(recipes) => Ok(recipes),
RecipeDocument::Wrapped { recipes } => Ok(recipes),
}
}
pub fn load_recipes_from_source(source: &str) -> Result<Vec<Recipe>, String> {
if source.trim().is_empty() {
return Err("empty recipe source".into());
}
if is_http_url(source) {
let response = reqwest::blocking::get(source).map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("request failed: {}", response.status()));
}
let text = response.text().map_err(|e| e.to_string())?;
parse_recipes_document(&text)
} else {
let path = expand_user_path(source);
let path = Path::new(&path);
if !path.exists() {
return Err(format!("recipe file not found: {}", path.to_string_lossy()));
}
let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
parse_recipes_document(&text)
}
}
pub fn load_recipes_with_fallback(
explicit_source: Option<String>,
default_path: &Path,
) -> Vec<Recipe> {
let builtin = builtin_recipes();
let candidates = [
explicit_source,
env::var("CLAWPAL_RECIPES_SOURCE").ok(),
Some(default_path.to_string_lossy().to_string()),
];
for candidate in candidates.iter().flatten() {
if candidate.trim().is_empty() {
continue;
}
if let Ok(recipes) = load_recipes_from_source(candidate) {
if !recipes.is_empty() {
return recipes;
}
}
}
builtin
}
pub fn find_recipe(id: &str) -> Option<Recipe> {
find_recipe_with_source(id, None)
}
pub fn find_recipe_with_source(id: &str, source: Option<String>) -> Option<Recipe> {
let paths = crate::models::resolve_paths();
let default_path = paths.clawpal_dir.join("recipes").join("recipes.json");
load_recipes_with_fallback(source, &default_path)
.into_iter()
.find(|r| r.id == id)
}
pub fn validate(recipe: &Recipe, params: &Map<String, Value>) -> Vec<String> {
let mut errors = Vec::new();
for p in &recipe.params {
if p.required && !params.contains_key(&p.id) {
errors.push(format!("missing required param: {}", p.id));
continue;
}
if let Some(v) = params.get(&p.id) {
let s = match v {
Value::String(s) => s.clone(),
_ => {
errors.push(format!("param {} must be string", p.id));
continue;
}
};
if let Some(min) = p.min_length {
if s.len() < min {
errors.push(format!("param {} too short", p.id));
}
}
if let Some(max) = p.max_length {
if s.len() > max {
errors.push(format!("param {} too long", p.id));
}
}
if let Some(pattern) = &p.pattern {
let re = Regex::new(pattern).map_err(|e| e.to_string()).ok();
if let Some(re) = re {
if !re.is_match(&s) {
errors.push(format!("param {} not match pattern", p.id));
}
} else {
errors.push("invalid validation pattern".into());
}
}
}
}
errors
}
fn render_patch_template(template: &str, params: &Map<String, Value>) -> String {
let mut text = template.to_string();
for (k, v) in params {
let placeholder = format!("{{{{{}}}}}", k);
let replacement = match v {
Value::String(s) => s.clone(),
_ => v.to_string(),
};
text = text.replace(&placeholder, &replacement);
}
text
}
pub fn build_candidate_config(
current: &Value,
recipe: &Recipe,
params: &Map<String, Value>,
) -> Result<(Value, Vec<ChangeItem>), String> {
let rendered = render_patch_template(&recipe.patch_template, params);
let patch: Value = json5::from_str(&rendered).map_err(|e| e.to_string())?;
let mut merged = current.clone();
let mut changes = Vec::new();
apply_merge_patch(&mut merged, &patch, "", &mut changes);
if recipe.impact_category == "high" {
for change in &mut changes {
change.risk = "high".into();
}
}
Ok((merged, changes))
}
fn apply_merge_patch(target: &mut Value, patch: &Value, prefix: &str, changes: &mut Vec<ChangeItem>) {
if patch.is_object() && target.is_object() {
let t = target.as_object_mut().unwrap();
for (k, pv) in patch.as_object().unwrap() {
let path = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
match pv {
Value::Null => {
if t.remove(k).is_some() {
changes.push(ChangeItem {
path: path.clone(),
op: "remove".into(),
risk: "medium".into(),
reason: None,
});
}
}
_ => {
if let Some(tv) = t.get_mut(k) {
if tv.is_object() && pv.is_object() {
apply_merge_patch(tv, pv, &path, changes);
} else {
*tv = pv.clone();
changes.push(ChangeItem {
path,
op: "replace".into(),
risk: "low".into(),
reason: None,
});
}
} else {
t.insert(k.clone(), pv.clone());
changes.push(ChangeItem {
path,
op: "add".into(),
risk: "low".into(),
reason: None,
});
}
}
}
}
} else {
*target = patch.clone();
changes.push(ChangeItem {
path: prefix.to_string(),
op: "replace".into(),
risk: "medium".into(),
reason: None,
});
}
}
pub fn collect_change_paths(current: &Value, patched: &Value) -> Vec<ChangeItem> {
if current == patched {
Vec::new()
} else {
vec![ChangeItem {
path: "root".to_string(),
op: "replace".to_string(),
risk: "medium".to_string(),
reason: None,
}]
}
}
pub fn format_diff(before: &Value, after: &Value) -> String {
let before_text = serde_json::to_string_pretty(before).unwrap_or_else(|_| "{}".into());
let after_text = serde_json::to_string_pretty(after).unwrap_or_else(|_| "{}".into());
format!("before:\n{}\n\nafter:\n{}", before_text, after_text)
}

26
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"identifier": "xyz.clawpal",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "ClawPal",
"width": 1200,
"height": 820,
"minWidth": 1024,
"minHeight": 680
}
]
},
"bundle": {
"active": true,
"icon": [],
"targets": ["deb", "dmg", "msi"]
}
}

73
src/App.tsx Normal file
View File

@@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { Home } from "./pages/Home";
import { Recipes } from "./pages/Recipes";
import { Install } from "./pages/Install";
import { History } from "./pages/History";
import { Doctor } from "./pages/Doctor";
import { Settings } from "./pages/Settings";
import { api } from "./lib/api";
type Route = "home" | "recipes" | "install" | "history" | "doctor" | "settings";
export function App() {
const [route, setRoute] = useState<Route>("home");
const [recipeId, setRecipeId] = useState<string | null>(null);
const [recipeSource, setRecipeSource] = useState<string | undefined>(undefined);
useEffect(() => {
if (!localStorage.getItem("clawpal_profiles_extracted")) {
api.extractModelProfilesFromConfig()
.then(() => localStorage.setItem("clawpal_profiles_extracted", "1"))
.catch(() => {});
}
}, []);
return (
<div className="app-shell">
<aside className="sidebar">
<h1>ClawPal</h1>
<nav>
<button className={route === "home" ? "active" : ""} onClick={() => setRoute("home")}>Home</button>
<button className={route === "recipes" || route === "install" ? "active" : ""} onClick={() => setRoute("recipes")}>Recipes</button>
<button className={route === "history" ? "active" : ""} onClick={() => setRoute("history")}>History</button>
<button className={route === "doctor" ? "active" : ""} onClick={() => setRoute("doctor")}>Doctor</button>
<div className="sidebar-divider" />
<button className={route === "settings" ? "active" : ""} onClick={() => setRoute("settings")}>Settings</button>
</nav>
</aside>
<main className="content">
{route === "home" && <Home />}
{route === "recipes" && (
<Recipes
onInstall={(id, source) => {
setRecipeId(id);
setRecipeSource(source);
setRoute("install");
}}
/>
)}
{route === "install" && recipeId && (
<Install
recipeId={recipeId}
recipeSource={recipeSource}
onDone={() => {
setRoute("recipes");
}}
/>
)}
{route === "install" && !recipeId && <p>No recipe selected.</p>}
{route === "history" && <History />}
{route === "doctor" && <Doctor />}
{route === "settings" && <Settings />}
{route === "install" && (
<button
onClick={() => setRoute("recipes")}
style={{ marginTop: 12 }}
>
Recipes
</button>
)}
</main>
</div>
);
}

130
src/components/Chat.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../lib/api";
interface Message {
role: "user" | "assistant";
content: string;
}
const AGENT_ID = "main";
const SESSION_KEY_PREFIX = "clawpal_chat_session_";
function loadSessionId(agent: string): string | undefined {
return localStorage.getItem(SESSION_KEY_PREFIX + agent) || undefined;
}
function saveSessionId(agent: string, sid: string) {
localStorage.setItem(SESSION_KEY_PREFIX + agent, sid);
}
function clearSessionId(agent: string) {
localStorage.removeItem(SESSION_KEY_PREFIX + agent);
}
const CLAWPAL_CONTEXT = `[ClawPal Context] You are responding inside ClawPal, a desktop GUI for OpenClaw configuration.
Rules:
- You are in READ-ONLY advisory mode. Do NOT execute commands, send messages, or modify config directly.
- When the user asks to change something, explain what should be changed and show the config diff, but do NOT apply it.
- Only discuss OpenClaw configuration topics (agents, models, channels, recipes, memory, sessions).
- Keep responses concise (2-3 sentences unless the user asks for detail).
User message: `;
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [agents, setAgents] = useState<string[]>([]);
const [agentId, setAgentId] = useState(AGENT_ID);
const [sessionId, setSessionId] = useState<string | undefined>(() => loadSessionId(AGENT_ID));
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api.listAgentIds().then(setAgents).catch(() => {});
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loading]);
const send = useCallback(async () => {
if (!input.trim() || loading) return;
const userMsg: Message = { role: "user", content: input.trim() };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setLoading(true);
try {
// Inject ClawPal context on first message of a session
const payload = sessionId ? userMsg.content : CLAWPAL_CONTEXT + userMsg.content;
const result = await api.chatViaOpenclaw(agentId, payload, sessionId);
// Extract session ID for conversation continuity
const meta = result.meta as Record<string, unknown> | undefined;
const agentMeta = meta?.agentMeta as Record<string, unknown> | undefined;
if (agentMeta?.sessionId) {
const sid = agentMeta.sessionId as string;
setSessionId(sid);
saveSessionId(agentId, sid);
}
// Extract reply text
const payloads = result.payloads as Array<{ text?: string }> | undefined;
const text = payloads?.map((p) => p.text).filter(Boolean).join("\n") || "No response";
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
} catch (err) {
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${err}` }]);
} finally {
setLoading(false);
}
}, [input, loading, agentId, sessionId]);
return (
<div className="home-chat">
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 0 8px" }}>
<h3 style={{ margin: 0 }}>Chat</h3>
<select
value={agentId}
onChange={(e) => { const a = e.target.value; setAgentId(a); setSessionId(loadSessionId(a)); setMessages([]); }}
style={{ fontSize: "0.8rem", padding: "2px 6px" }}
>
{agents.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
<button
type="button"
onClick={() => { clearSessionId(agentId); setSessionId(undefined); setMessages([]); }}
style={{ fontSize: "0.75rem", padding: "2px 8px", opacity: 0.7 }}
>
New
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", marginBottom: 8 }}>
{messages.map((msg, i) => (
<div key={i} style={{ marginBottom: 8, textAlign: msg.role === "user" ? "right" : "left" }}>
<div style={{
display: "inline-block",
background: msg.role === "user" ? "#2d3560" : "var(--panel)",
padding: "8px 12px",
borderRadius: 8,
maxWidth: "90%",
textAlign: "left",
border: "1px solid #29325a",
}}>
<div style={{ whiteSpace: "pre-wrap", fontSize: "0.9rem" }}>{msg.content}</div>
</div>
</div>
))}
{loading && <div style={{ opacity: 0.5, fontSize: "0.9rem" }}>Thinking...</div>}
<div ref={bottomRef} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder="Ask your OpenClaw agent..."
style={{ flex: 1 }}
/>
<button onClick={send} disabled={loading}>Send</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export function DiffViewer({ value }: { value: string }) {
return (
<pre className="diff-viewer">
{value}
</pre>
);
}

View File

@@ -0,0 +1,92 @@
import React, { useMemo, useState } from "react";
import type { Recipe, RecipeParam } from "../lib/types";
function validateField(param: RecipeParam, value: string): string | null {
const trim = value.trim();
if (param.required && trim.length === 0) {
return `${param.label} is required`;
}
if (param.minLength !== undefined && trim.length < param.minLength) {
return `${param.label} is too short`;
}
if (param.maxLength !== undefined && trim.length > param.maxLength) {
return `${param.label} is too long`;
}
if (param.pattern && trim.length > 0) {
try {
if (!new RegExp(param.pattern).test(trim)) {
return `${param.label} format is invalid`;
}
} catch {
return `${param.label} has invalid validation rule`;
}
}
return null;
}
export function ParamForm({
recipe,
values,
onChange,
onSubmit,
}: {
recipe: Recipe;
values: Record<string, string>;
onChange: (id: string, value: string) => void;
onSubmit: () => void;
}) {
const [touched, setTouched] = useState<Record<string, boolean>>({});
const errors = useMemo(() => {
const next: Record<string, string> = {};
for (const param of recipe.params) {
const err = validateField(param, values[param.id] || "");
if (err) {
next[param.id] = err;
}
}
return next;
}, [recipe.params, values]);
const hasError = Object.keys(errors).length > 0;
return (
<form className="param-form" onSubmit={(e) => {
e.preventDefault();
if (hasError) {
return;
}
onSubmit();
}}>
{recipe.params.map((param: RecipeParam) => (
<label key={param.id}>
<span>{param.label}</span>
{param.type === "textarea" ? (
<textarea
value={values[param.id] || ""}
placeholder={param.placeholder}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
) : (
<input
value={values[param.id] || ""}
placeholder={param.placeholder}
required={param.required}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
)}
{touched[param.id] && errors[param.id] ? (
<small style={{ color: "#f98b8b", display: "block" }}>{errors[param.id]}</small>
) : null}
</label>
))}
<button type="submit" disabled={hasError}>Preview</button>
</form>
);
}

View File

@@ -0,0 +1,19 @@
import type { Recipe } from "../lib/types";
export function RecipeCard({ recipe, onInstall }: { recipe: Recipe; onInstall: (id: string) => void }) {
return (
<article className="recipe-card">
<h3>{recipe.name}</h3>
<p>{recipe.description}</p>
<div className="meta">
{recipe.tags.map((t) => (
<span key={t} className="tag">
{t}
</span>
))}
</div>
<p>Impact: {recipe.impactCategory}</p>
<button onClick={() => onInstall(recipe.id)}>Install</button>
</article>
);
}

67
src/lib/api.ts Normal file
View File

@@ -0,0 +1,67 @@
import { invoke } from "@tauri-apps/api/core";
import type { AgentOverview, ApplyResult, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
export const api = {
getSystemStatus: (): Promise<SystemStatus> =>
invoke("get_system_status", {}),
getStatusLight: (): Promise<StatusLight> =>
invoke("get_status_light", {}),
getCachedModelCatalog: (): Promise<ModelCatalogProvider[]> =>
invoke("get_cached_model_catalog", {}),
refreshModelCatalog: (): Promise<ModelCatalogProvider[]> =>
invoke("refresh_model_catalog", {}),
listRecipes: (source?: string): Promise<Recipe[]> =>
invoke("list_recipes", source ? { source } : {}),
previewApply: (recipeId: string, params: Record<string, string>, source?: string): Promise<PreviewResult> =>
invoke("preview_apply", { recipeId, params, source }),
applyRecipe: (recipeId: string, params: Record<string, string>, source?: string): Promise<ApplyResult> =>
invoke("apply_recipe", { recipeId, params, source }),
listHistory: (limit = 20, offset = 0): Promise<{ items: HistoryItem[] }> =>
invoke("list_history", { limit, offset }),
previewRollback: (snapshotId: string): Promise<PreviewResult> =>
invoke("preview_rollback", { snapshotId }),
rollback: (snapshotId: string): Promise<ApplyResult> =>
invoke("rollback", { snapshotId }),
listModelProfiles: (): Promise<ModelProfile[]> =>
invoke("list_model_profiles", {}),
listModelCatalog: (): Promise<ModelCatalogProvider[]> =>
invoke("list_model_catalog", {}),
extractModelProfilesFromConfig: (): Promise<{ created: number; reused: number; skippedInvalid: number }> =>
invoke("extract_model_profiles_from_config", {}),
upsertModelProfile: (profile: ModelProfile): Promise<ModelProfile> =>
invoke("upsert_model_profile", { profile }),
deleteModelProfile: (profileId: string): Promise<boolean> =>
invoke("delete_model_profile", { profile_id: profileId }),
resolveApiKeys: (): Promise<ResolvedApiKey[]> =>
invoke("resolve_api_keys", {}),
listAgentIds: (): Promise<string[]> =>
invoke("list_agent_ids", {}),
listAgentsOverview: (): Promise<AgentOverview[]> =>
invoke("list_agents_overview", {}),
listMemoryFiles: (): Promise<MemoryFile[]> =>
invoke("list_memory_files", {}),
deleteMemoryFile: (filePath: string): Promise<boolean> =>
invoke("delete_memory_file", { path: filePath }),
clearMemory: (): Promise<number> =>
invoke("clear_memory", {}),
listSessionFiles: (): Promise<SessionFile[]> =>
invoke("list_session_files", {}),
deleteSessionFile: (filePath: string): Promise<boolean> =>
invoke("delete_session_file", { path: filePath }),
clearAllSessions: (): Promise<number> =>
invoke("clear_all_sessions", {}),
clearAgentSessions: (agentId: string): Promise<number> =>
invoke("clear_agent_sessions", { agentId }),
runDoctor: (): Promise<DoctorReport> =>
invoke("run_doctor_command", {}),
fixIssues: (ids: string[]): Promise<{ ok: boolean; applied: string[]; remainingIssues: string[] }> =>
invoke("fix_issues", { ids }),
readRawConfig: (): Promise<string> =>
invoke("read_raw_config", {}),
resolveFullApiKey: (profileId: string): Promise<string> =>
invoke("resolve_full_api_key", { profileId }),
openUrl: (url: string): Promise<void> =>
invoke("open_url", { url }),
chatViaOpenclaw: (agentId: string, message: string, sessionId?: string): Promise<Record<string, unknown>> =>
invoke("chat_via_openclaw", { agentId, message, sessionId }),
};

129
src/lib/chat.ts Normal file
View File

@@ -0,0 +1,129 @@
import { api } from "./api";
import type { ModelProfile } from "./types";
export const SYSTEM_PROMPT = `You are ClawPal, an AI assistant that helps users configure OpenClaw.
You have tools to read the current config, preview changes, apply changes, list recipes, list agents, and run diagnostics.
When a user asks to change configuration:
1. Read the current config to understand what exists
2. Generate the appropriate config patch
3. Preview the change and show the diff to the user
4. Only apply after the user confirms
Always explain what you're about to do before doing it. Be concise.`;
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export const TOOLS: ToolDef[] = [
{
type: "function",
function: {
name: "read_config",
description: "Read the current OpenClaw configuration file",
parameters: { type: "object", properties: {}, required: [] },
},
},
{
type: "function",
function: {
name: "list_agents",
description: "List all configured agents with their models and channels",
parameters: { type: "object", properties: {}, required: [] },
},
},
{
type: "function",
function: {
name: "list_recipes",
description: "List available configuration recipes",
parameters: { type: "object", properties: {}, required: [] },
},
},
{
type: "function",
function: {
name: "preview_change",
description: "Preview a configuration change by providing a recipe ID and parameters",
parameters: {
type: "object",
properties: {
recipe_id: { type: "string", description: "The recipe ID to preview" },
params: { type: "object", description: "Parameters for the recipe" },
},
required: ["recipe_id", "params"],
},
},
},
{
type: "function",
function: {
name: "run_doctor",
description: "Run configuration diagnostics to check for issues",
parameters: { type: "object", properties: {}, required: [] },
},
},
];
export async function executeToolCall(name: string, args: Record<string, unknown>): Promise<string> {
switch (name) {
case "read_config":
return await api.readRawConfig();
case "list_agents":
return JSON.stringify(await api.listAgentsOverview(), null, 2);
case "list_recipes": {
const recipes = await api.listRecipes();
return JSON.stringify(recipes.map((r) => ({ id: r.id, name: r.name, description: r.description })), null, 2);
}
case "preview_change": {
const recipeId = args.recipe_id as string;
const params = args.params as Record<string, string>;
const preview = await api.previewApply(recipeId, params);
return JSON.stringify({ diff: preview.diff, warnings: preview.warnings, impactLevel: preview.impactLevel });
}
case "run_doctor": {
const report = await api.runDoctor();
return JSON.stringify(report, null, 2);
}
default:
return `Unknown tool: ${name}`;
}
}
export function getBaseUrl(profile: ModelProfile): string {
// Always use provider defaults for chat; extracted baseUrls from config
// may target a different API format (e.g. Anthropic proxy for MiniMax)
return getDefaultBaseUrl(profile.provider);
}
function getDefaultBaseUrl(provider: string): string {
switch (provider.toLowerCase()) {
case "openai":
case "openai-codex":
return "https://api.openai.com/v1";
case "anthropic":
return "https://api.anthropic.com/v1";
case "google":
case "gemini":
return "https://generativelanguage.googleapis.com/v1beta/openai";
case "deepseek":
return "https://api.deepseek.com/v1";
case "groq":
return "https://api.groq.com/openai/v1";
case "mistral":
return "https://api.mistral.ai/v1";
case "kimi-coding":
case "moonshot":
return "https://api.moonshot.cn/v1";
case "minimax":
case "minimax-portal":
return "https://api.minimax.chat/v1";
default:
return "https://api.openai.com/v1";
}
}

57
src/lib/recipe_catalog.ts Normal file
View File

@@ -0,0 +1,57 @@
export const builtinRecipes = [
{
id: "discord-channel-persona",
name: "Discord channel persona",
description: "Inject different system prompt for one Discord channel",
version: "1.0.0",
tags: ["discord", "persona", "beginner"],
difficulty: "easy",
params: [
{
id: "guild_id",
label: "Guild ID",
type: "string",
required: true,
pattern: "^[0-9]+$",
minLength: 17,
maxLength: 20,
placeholder: "Copy guild id",
},
{
id: "channel_id",
label: "Channel ID",
type: "string",
required: true,
pattern: "^[0-9]+$",
minLength: 17,
maxLength: 20,
placeholder: "Copy channel id",
},
{
id: "persona",
label: "Persona description",
type: "textarea",
required: true,
minLength: 1,
placeholder: "You are...",
},
],
patchTemplate: `{
"channels": {
"discord": {
"guilds": {
"{{guild_id}}": {
"channels": {
"{{channel_id}}": {
"systemPrompt": "{{persona}}"
}
}
}
}
}
}
}`,
impactCategory: "low",
impactSummary: "Add/modify channel persona",
},
];

46
src/lib/state.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { Recipe, PreviewResult, HistoryItem, SystemStatus, DoctorReport } from "./types";
export interface AppState {
recipes: Recipe[];
history: HistoryItem[];
status: SystemStatus | null;
doctor: DoctorReport | null;
lastPreview: PreviewResult | null;
message: string;
}
export const initialState: AppState = {
recipes: [],
history: [],
status: null,
doctor: null,
lastPreview: null,
message: "",
};
export type Action =
| { type: "setRecipes"; recipes: Recipe[] }
| { type: "setHistory"; history: HistoryItem[] }
| { type: "setStatus"; status: SystemStatus }
| { type: "setDoctor"; doctor: DoctorReport }
| { type: "setPreview"; preview: PreviewResult }
| { type: "setMessage"; message: string };
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "setRecipes":
return { ...state, recipes: action.recipes };
case "setHistory":
return { ...state, history: action.history };
case "setStatus":
return { ...state, status: action.status };
case "setDoctor":
return { ...state, doctor: action.doctor };
case "setPreview":
return { ...state, lastPreview: action.preview };
case "setMessage":
return { ...state, message: action.message };
default:
return state;
}
}

169
src/lib/types.ts Normal file
View File

@@ -0,0 +1,169 @@
export type Severity = "low" | "medium" | "high";
export interface RecipeParam {
id: string;
label: string;
type: "string" | "number" | "boolean" | "textarea";
required: boolean;
pattern?: string;
minLength?: number;
maxLength?: number;
placeholder?: string;
}
export interface Recipe {
id: string;
name: string;
description: string;
version: string;
tags: string[];
difficulty: "easy" | "normal" | "advanced";
params: RecipeParam[];
patchTemplate: string;
impactCategory: string;
impactSummary: string;
}
export interface ChangeItem {
path: string;
op: string;
risk: string;
reason?: string;
}
export interface PreviewResult {
recipeId: string;
diff: string;
changes: ChangeItem[];
overwritesExisting: boolean;
canRollback: boolean;
impactLevel: string;
warnings: string[];
}
export interface ApplyResult {
ok: boolean;
snapshotId?: string;
configPath: string;
backupPath?: string;
warnings: string[];
errors: string[];
}
export interface SystemStatus {
healthy: boolean;
configPath: string;
openclawDir: string;
clawpalDir: string;
openclawVersion: string;
activeAgents: number;
snapshots: number;
openclawUpdate?: {
installedVersion: string;
latestVersion?: string;
upgradeAvailable: boolean;
channel?: string;
details?: string;
source: string;
checkedAt: string;
};
channels: {
configuredChannels: number;
channelModelOverrides: number;
channelExamples: string[];
};
models: {
globalDefaultModel?: string;
agentOverrides: string[];
channelOverrides: string[];
};
memory: {
fileCount: number;
totalBytes: number;
files: { path: string; sizeBytes: number }[];
};
sessions: {
totalSessionFiles: number;
totalArchiveFiles: number;
totalBytes: number;
byAgent: { agent: string; sessionFiles: number; archiveFiles: number; totalBytes: number }[];
};
}
export interface MemoryFile {
path: string;
relativePath: string;
sizeBytes: number;
}
export interface SessionFile {
path: string;
relativePath: string;
agent: string;
kind: "sessions" | "archive";
sizeBytes: number;
}
export interface ModelProfile {
id: string;
name: string;
provider: string;
model: string;
authRef: string;
apiKey?: string;
baseUrl?: string;
description?: string;
enabled: boolean;
}
export interface ModelCatalogModel {
id: string;
name?: string;
}
export interface ModelCatalogProvider {
provider: string;
baseUrl?: string;
models: ModelCatalogModel[];
}
export interface ResolvedApiKey {
profileId: string;
maskedKey: string;
}
export interface HistoryItem {
id: string;
recipeId?: string;
createdAt: string;
source: string;
canRollback: boolean;
}
export interface DoctorIssue {
id: string;
code: string;
severity: "error" | "warn" | "info";
message: string;
autoFixable: boolean;
fixHint?: string;
}
export interface DoctorReport {
ok: boolean;
score: number;
issues: DoctorIssue[];
}
export interface AgentOverview {
id: string;
model: string | null;
channels: string[];
online: boolean;
}
export interface StatusLight {
healthy: boolean;
activeAgents: number;
globalDefaultModel?: string;
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

193
src/pages/Doctor.tsx Normal file
View File

@@ -0,0 +1,193 @@
import React, { useEffect, useMemo, useReducer, useState } from "react";
import { api } from "../lib/api";
import { initialState, reducer } from "../lib/state";
import type { MemoryFile, SessionFile } from "../lib/types";
function formatBytes(bytes: number) {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let index = 0;
let value = bytes;
while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }
return `${value.toFixed(1)} ${units[index]}`;
}
export function Doctor() {
const [state, dispatch] = useReducer(reducer, initialState);
const [memoryFiles, setMemoryFiles] = useState<MemoryFile[]>([]);
const [sessionFiles, setSessionFiles] = useState<SessionFile[]>([]);
const [dataMessage, setDataMessage] = useState("");
const hasReport = Boolean(state.doctor);
const autoFixable = hasReport
? state.doctor!.issues.filter((issue) => issue.autoFixable).map((issue) => issue.id)
: [];
const agents = useMemo(() => {
const map = new Map<string, { count: number; size: number }>();
for (const f of sessionFiles) {
const entry = map.get(f.agent) || { count: 0, size: 0 };
entry.count += 1;
entry.size += f.sizeBytes;
map.set(f.agent, entry);
}
return Array.from(map.entries()).map(([agent, info]) => ({
agent,
count: info.count,
size: info.size,
}));
}, [sessionFiles]);
const totalMemoryBytes = useMemo(
() => memoryFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
[memoryFiles],
);
const totalSessionBytes = useMemo(
() => sessionFiles.reduce((sum, f) => sum + f.sizeBytes, 0),
[sessionFiles],
);
function refreshData() {
api.listMemoryFiles().then(setMemoryFiles).catch(() => setDataMessage("Failed to load memory files"));
api.listSessionFiles().then(setSessionFiles).catch(() => setDataMessage("Failed to load session files"));
}
useEffect(() => {
api
.runDoctor()
.then((report) => dispatch({ type: "setDoctor", doctor: report }))
.catch(() => dispatch({ type: "setMessage", message: "Failed to run doctor" }));
refreshData();
}, []);
return (
<section>
<h2>Doctor</h2>
{/* ── Config Diagnostics ── */}
{state.doctor && (
<div>
<p>Health score: {state.doctor.score}</p>
<ul>
{state.doctor.issues.map((issue) => (
<li key={issue.id}>
{issue.severity.toUpperCase()} {issue.message}
{issue.autoFixable && (
<button
onClick={() => {
api
.fixIssues([issue.id])
.then(() => api.runDoctor())
.then((report) => dispatch({ type: "setDoctor", doctor: report }))
.catch(() =>
dispatch({
type: "setMessage",
message: "Failed to fix issue",
}),
);
}}
>
fix
</button>
)}
</li>
))}
</ul>
<button
onClick={() => {
api
.fixIssues(autoFixable)
.then(() => api.runDoctor())
.then((report) => dispatch({ type: "setDoctor", doctor: report }))
.catch(() =>
dispatch({
type: "setMessage",
message: "Failed to fix all issues",
}),
);
}}
disabled={!autoFixable.length}
>
Fix all auto issues
</button>
<button
onClick={() =>
api
.runDoctor()
.then((report) => dispatch({ type: "setDoctor", doctor: report }))
.catch(() => dispatch({ type: "setMessage", message: "Refresh failed" }))
}
>
Refresh
</button>
</div>
)}
{!hasReport ? <button onClick={() => api.runDoctor().then((report) => dispatch({ type: "setDoctor", doctor: report }))}>Run Doctor</button> : null}
<p>{state.message}</p>
{/* ── Data Cleanup ── */}
<h3>Data Cleanup</h3>
{dataMessage && <p>{dataMessage}</p>}
<div className="status-grid">
{/* Memory */}
<div className="card">
<h4>Memory</h4>
<p>{memoryFiles.length} files ({formatBytes(totalMemoryBytes)})</p>
<button
disabled={memoryFiles.length === 0}
onClick={() => {
api
.clearMemory()
.then((count) => {
setDataMessage(`Cleared ${count} memory file(s)`);
refreshData();
})
.catch(() => setDataMessage("Failed to clear memory"));
}}
>
Clear all memory
</button>
</div>
{/* Sessions */}
<div className="card">
<h4>Sessions</h4>
<p>{sessionFiles.length} files ({formatBytes(totalSessionBytes)})</p>
{agents.map((a) => (
<div key={a.agent} style={{ display: "flex", alignItems: "center", gap: 8, margin: "4px 0" }}>
<span>{a.agent}: {a.count} files ({formatBytes(a.size)})</span>
<button
onClick={() => {
api
.clearAgentSessions(a.agent)
.then((count) => {
setDataMessage(`Cleared ${count} session file(s) for ${a.agent}`);
refreshData();
})
.catch(() => setDataMessage(`Failed to clear sessions for ${a.agent}`));
}}
>
Clear
</button>
</div>
))}
<button
disabled={sessionFiles.length === 0}
onClick={() => {
api
.clearAllSessions()
.then((count) => {
setDataMessage(`Cleared ${count} session file(s)`);
refreshData();
})
.catch(() => setDataMessage("Failed to clear sessions"));
}}
>
Clear all sessions
</button>
</div>
</div>
</section>
);
}

69
src/pages/History.tsx Normal file
View File

@@ -0,0 +1,69 @@
import React, { useEffect, useReducer } from "react";
import { api } from "../lib/api";
import { initialState, reducer } from "../lib/state";
import { DiffViewer } from "../components/DiffViewer";
export function History() {
const [state, dispatch] = useReducer(reducer, initialState);
const refreshHistory = () =>
api.listHistory(50, 0)
.then((resp) => dispatch({ type: "setHistory", history: resp.items }))
.catch(() => dispatch({ type: "setMessage", message: "Failed to load history" }));
useEffect(() => {
refreshHistory();
}, []);
return (
<section>
<h2>History</h2>
<div className="history-list">
{state.history.map((item) => (
<article key={item.id} className="history-item">
<p>
{item.createdAt} · {item.recipeId || "manual"} · {item.source}
{!item.canRollback ? " · not rollbackable" : ""}
</p>
<button
onClick={async () => {
try {
const preview = await api.previewRollback(item.id);
dispatch({ type: "setPreview", preview });
} catch (err) {
dispatch({ type: "setMessage", message: String(err) });
}
}}
disabled={!item.canRollback}
>
Preview rollback
</button>
<button
onClick={async () => {
if (!item.canRollback) {
dispatch({
type: "setMessage",
message: "This snapshot cannot be rolled back",
});
return;
}
try {
await api.rollback(item.id);
dispatch({ type: "setMessage", message: "Rollback completed" });
await refreshHistory();
} catch (err) {
dispatch({ type: "setMessage", message: String(err) });
}
}}
disabled={!item.canRollback}
>
Rollback
</button>
</article>
))}
</div>
{state.lastPreview && <DiffViewer value={state.lastPreview.diff} />}
<button onClick={refreshHistory}>Refresh</button>
<p>{state.message}</p>
</section>
);
}

168
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { Chat } from "../components/Chat";
import type { StatusLight, AgentOverview, Recipe, HistoryItem } from "../lib/types";
export function Home() {
const [status, setStatus] = useState<StatusLight | null>(null);
const [version, setVersion] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<{ available: boolean; latest?: string } | null>(null);
const [agents, setAgents] = useState<AgentOverview[]>([]);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [history, setHistory] = useState<HistoryItem[]>([]);
// Fast calls: render immediately
useEffect(() => {
api.getStatusLight().then(setStatus).catch(() => {});
}, []);
useEffect(() => {
api.listAgentsOverview().then(setAgents).catch(() => {});
}, []);
useEffect(() => {
api.listRecipes().then((r) => setRecipes(r.slice(0, 4))).catch(() => {});
}, []);
useEffect(() => {
api.listHistory(5, 0).then((h) => setHistory(h.items)).catch(() => {});
}, []);
// Heavy call: version + update check, deferred
useEffect(() => {
const timer = setTimeout(() => {
api.getSystemStatus().then((s) => {
setVersion(s.openclawVersion);
if (s.openclawUpdate) {
setUpdateInfo({
available: s.openclawUpdate.upgradeAvailable,
latest: s.openclawUpdate.latestVersion,
});
}
}).catch(() => {});
}, 100);
return () => clearTimeout(timer);
}, []);
return (
<div className="home-layout">
<div className="home-main">
<h2>Home</h2>
{/* Status Summary */}
<h3>Status</h3>
<div className="status-grid">
<div className="card">
<div style={{ opacity: 0.7, fontSize: "0.85rem" }}>Health</div>
<div style={{ fontSize: "1.1rem", marginTop: 4 }}>
{status ? (status.healthy ? "Healthy" : "Unhealthy") : "..."}
</div>
</div>
<div className="card">
<div style={{ opacity: 0.7, fontSize: "0.85rem" }}>OpenClaw Version</div>
<div style={{ fontSize: "1.1rem", marginTop: 4 }}>
{version || "..."}
</div>
{updateInfo?.available && (
<div style={{ marginTop: 4 }}>
<div style={{ color: "var(--accent)", fontSize: "0.85rem" }}>
Update available: {updateInfo.latest}
</div>
<button
style={{ marginTop: 6, fontSize: "0.8rem", padding: "4px 10px" }}
onClick={() => api.openUrl("https://github.com/openclaw/openclaw/releases")}
>
View update
</button>
</div>
)}
</div>
<div className="card">
<div style={{ opacity: 0.7, fontSize: "0.85rem" }}>Default Model</div>
<div style={{ fontSize: "1.1rem", marginTop: 4 }}>
{status ? (status.globalDefaultModel || "not set") : "..."}
</div>
</div>
</div>
{/* Agents Overview */}
<h3 style={{ marginTop: 24 }}>Agents</h3>
{agents.length === 0 ? (
<p style={{ opacity: 0.6 }}>No agents found.</p>
) : (
<div className="status-grid">
{agents.map((agent) => (
<div className="card" key={agent.id}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>{agent.id}</strong>
<span
style={{
fontSize: "0.8rem",
padding: "2px 8px",
borderRadius: 6,
background: agent.online ? "rgba(80,200,120,0.18)" : "rgba(255,107,107,0.15)",
color: agent.online ? "#50c878" : "#ff6b6b",
}}
>
{agent.online ? "online" : "offline"}
</span>
</div>
<div style={{ opacity: 0.7, fontSize: "0.85rem", marginTop: 6 }}>
Model: {agent.model || "default"}
</div>
</div>
))}
</div>
)}
{/* Recommended Recipes */}
<h3 style={{ marginTop: 24 }}>Recommended Recipes</h3>
{recipes.length === 0 ? (
<p style={{ opacity: 0.6 }}>No recipes available.</p>
) : (
<div className="status-grid">
{recipes.map((recipe) => (
<div className="card" key={recipe.id}>
<strong>{recipe.name}</strong>
<div style={{ opacity: 0.8, fontSize: "0.9rem", marginTop: 6 }}>
{recipe.description}
</div>
<div style={{ opacity: 0.6, fontSize: "0.8rem", marginTop: 8 }}>
{recipe.difficulty} &middot; {recipe.impactCategory}
</div>
</div>
))}
</div>
)}
{/* Recent Activity */}
<h3 style={{ marginTop: 24 }}>Recent Activity</h3>
{history.length === 0 ? (
<p style={{ opacity: 0.6 }}>No recent activity.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{history.map((item) => (
<div className="card" key={item.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<span style={{ fontWeight: 500 }}>{item.recipeId || "manual change"}</span>
<span style={{ opacity: 0.6, marginLeft: 10, fontSize: "0.85rem" }}>
{item.source}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{item.canRollback && (
<span style={{ fontSize: "0.8rem", opacity: 0.6 }}>rollback available</span>
)}
<span style={{ opacity: 0.5, fontSize: "0.85rem" }}>
{item.createdAt}
</span>
</div>
</div>
))}
</div>
)}
</div>
<Chat />
</div>
);
}

91
src/pages/Install.tsx Normal file
View File

@@ -0,0 +1,91 @@
import React, { useEffect, useReducer, useState } from "react";
import { api } from "../lib/api";
import { ParamForm } from "../components/ParamForm";
import { DiffViewer } from "../components/DiffViewer";
import { initialState, reducer } from "../lib/state";
export function Install({
recipeId,
onDone,
recipeSource,
}: {
recipeId: string;
onDone?: () => void;
recipeSource?: string;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const [params, setParams] = useState<Record<string, string>>({});
const [isApplying, setIsApplying] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
useEffect(() => {
api.listRecipes(recipeSource).then((recipes) => {
const recipe = recipes.find((it) => it.id === recipeId);
dispatch({ type: "setRecipes", recipes });
if (!recipe) return;
const defaults: Record<string, string> = {};
for (const p of recipe.params) {
defaults[p.id] = "";
}
setParams(defaults);
});
}, [recipeId, recipeSource]);
const recipe = state.recipes.find((r) => r.id === recipeId);
if (!recipe) return <div>Recipe not found</div>;
return (
<section>
<h2>Install {recipe.name}</h2>
<ParamForm
recipe={recipe}
values={params}
onChange={(id, value) => setParams((prev) => ({ ...prev, [id]: value }))}
onSubmit={() => {
setIsPreviewing(true);
api.previewApply(recipe.id, params, recipeSource)
.then((preview) => dispatch({ type: "setPreview", preview }))
.catch((err) => dispatch({ type: "setMessage", message: String(err) }))
.finally(() => setIsPreviewing(false));
}}
/>
{state.lastPreview && (
<section>
<h3>Preview</h3>
<DiffViewer value={state.lastPreview.diff} />
<button
disabled={isApplying}
onClick={() => {
setIsApplying(true);
api.applyRecipe(recipe.id, params, recipeSource)
.then((result) => {
if (!result.ok) {
const errors = result.errors.length ? result.errors.join(", ") : "failed";
dispatch({ type: "setMessage", message: `Apply failed: ${errors}` });
return;
}
dispatch({
type: "setMessage",
message: result.snapshotId
? `Applied successfully. Snapshot: ${result.snapshotId}`
: "Applied successfully",
});
if (onDone) {
onDone();
}
})
.catch((err) => dispatch({ type: "setMessage", message: String(err) }))
.finally(() => setIsApplying(false));
}}
>
Apply
</button>
{isPreviewing ? <span> ...previewing</span> : null}
{isApplying ? <span> ...applying</span> : null}
</section>
)}
<p>{state.message}</p>
</section>
);
}

68
src/pages/Recipes.tsx Normal file
View File

@@ -0,0 +1,68 @@
import React, { useEffect, useReducer, useState } from "react";
import { api } from "../lib/api";
import { RecipeCard } from "../components/RecipeCard";
import { initialState, reducer } from "../lib/state";
import type { Recipe } from "../lib/types";
export function Recipes({
onInstall,
}: {
onInstall: (id: string, source?: string) => void;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const [source, setSource] = useState("");
const [loadedSource, setLoadedSource] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const load = (nextSource: string) => {
setIsLoading(true);
const value = nextSource.trim();
api
.listRecipes(value || undefined)
.then((recipes) => {
setLoadedSource(value || undefined);
dispatch({ type: "setRecipes", recipes });
})
.catch(() => dispatch({ type: "setMessage", message: "Failed to load recipes" }))
.finally(() => setIsLoading(false));
};
useEffect(() => {
load("");
}, []);
const onLoadSource = (event: React.FormEvent) => {
event.preventDefault();
load(source);
};
return (
<section>
<h2>Recipes</h2>
<form onSubmit={onLoadSource} style={{ marginBottom: 8 }}>
<label>
Recipe source (file path or URL)
<input
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder="/path/recipes.json or https://example.com/recipes.json"
style={{ marginLeft: 8, width: 380 }}
/>
</label>
<button type="submit" style={{ marginLeft: 8 }}>
{isLoading ? "Loading..." : "Load"}
</button>
</form>
<p style={{ opacity: 0.8, marginTop: 0 }}>Loaded from: {loadedSource || "builtin / clawpal recipes"}</p>
<div className="recipe-grid">
{state.recipes.map((recipe: Recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
onInstall={() => onInstall(recipe.id, loadedSource)}
/>
))}
</div>
</section>
);
}

347
src/pages/Settings.tsx Normal file
View File

@@ -0,0 +1,347 @@
import React, { useEffect, useMemo, useState } from "react";
import { api } from "../lib/api";
import type { ModelCatalogProvider, ModelProfile, ResolvedApiKey } from "../lib/types";
type ProfileForm = {
id: string;
provider: string;
model: string;
apiKey: string;
useCustomUrl: boolean;
baseUrl: string;
enabled: boolean;
};
function emptyForm(): ProfileForm {
return {
id: "",
provider: "",
model: "",
apiKey: "",
useCustomUrl: false,
baseUrl: "",
enabled: true,
};
}
const CHAT_PROFILE_KEY = "clawpal_chat_profile";
export function Settings() {
const [profiles, setProfiles] = useState<ModelProfile[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogProvider[]>([]);
const [apiKeys, setApiKeys] = useState<ResolvedApiKey[]>([]);
const [form, setForm] = useState<ProfileForm>(emptyForm());
const [message, setMessage] = useState("");
const [chatProfileId, setChatProfileId] = useState(
() => localStorage.getItem(CHAT_PROFILE_KEY) || "",
);
const [catalogRefreshed, setCatalogRefreshed] = useState(false);
// Load profiles and API keys immediately (fast)
const refreshProfiles = () => {
api.listModelProfiles().then(setProfiles).catch(() => {});
api.resolveApiKeys().then(setApiKeys).catch(() => {});
};
useEffect(refreshProfiles, []);
// Load catalog from cache instantly (no CLI calls)
useEffect(() => {
api.getCachedModelCatalog().then(setCatalog).catch(() => {});
}, []);
// Refresh catalog from CLI when user focuses provider/model input
const ensureCatalog = () => {
if (catalogRefreshed) return;
setCatalogRefreshed(true);
api.refreshModelCatalog().then(setCatalog).catch(() => {});
};
const maskedKeyMap = useMemo(() => {
const map = new Map<string, string>();
for (const entry of apiKeys) {
map.set(entry.profileId, entry.maskedKey);
}
return map;
}, [apiKeys]);
const modelCandidates = useMemo(() => {
const found = catalog.find((c) => c.provider === form.provider);
return found?.models || [];
}, [catalog, form.provider]);
const upsert = (event: React.FormEvent) => {
event.preventDefault();
if (!form.provider || !form.model) {
setMessage("Provider and Model are required");
return;
}
if (!form.apiKey && !form.id) {
setMessage("API Key is required");
return;
}
const profileData: ModelProfile = {
id: form.id || "",
name: `${form.provider}/${form.model}`,
provider: form.provider,
model: form.model,
authRef: "",
apiKey: form.apiKey || undefined,
baseUrl: form.useCustomUrl && form.baseUrl ? form.baseUrl : undefined,
enabled: form.enabled,
};
api
.upsertModelProfile(profileData)
.then(() => {
setMessage("Profile saved");
setForm(emptyForm());
refreshProfiles();
})
.catch(() => setMessage("Save failed"));
};
const editProfile = (profile: ModelProfile) => {
setForm({
id: profile.id,
provider: profile.provider,
model: profile.model,
apiKey: "",
useCustomUrl: !!profile.baseUrl,
baseUrl: profile.baseUrl || "",
enabled: profile.enabled,
});
};
const deleteProfile = (id: string) => {
api
.deleteModelProfile(id)
.then(() => {
setMessage("Profile deleted");
if (form.id === id) {
setForm(emptyForm());
}
if (chatProfileId === id) {
setChatProfileId("");
localStorage.removeItem(CHAT_PROFILE_KEY);
}
refreshProfiles();
})
.catch(() => setMessage("Delete failed"));
};
const handleChatProfileChange = (value: string) => {
setChatProfileId(value);
if (value) {
localStorage.setItem(CHAT_PROFILE_KEY, value);
} else {
localStorage.removeItem(CHAT_PROFILE_KEY);
}
};
return (
<section>
<h2>Settings</h2>
{/* ---- Model Profiles ---- */}
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "1fr 1fr",
alignItems: "start",
}}
>
{/* Create / Edit form */}
<article className="card">
<h3>{form.id ? "Edit Profile" : "Add Profile"}</h3>
<form onSubmit={upsert} className="param-form">
<label>
Provider
<input
placeholder="e.g. openai"
value={form.provider}
onChange={(e) =>
setForm((p) => ({ ...p, provider: e.target.value, model: "" }))
}
onFocus={ensureCatalog}
list="settings-provider-list"
/>
<datalist id="settings-provider-list">
{catalog.map((c) => (
<option key={c.provider} value={c.provider} />
))}
</datalist>
</label>
<label>
Model
<input
placeholder="e.g. gpt-4o"
value={form.model}
onChange={(e) =>
setForm((p) => ({ ...p, model: e.target.value }))
}
onFocus={ensureCatalog}
list="settings-model-list"
/>
<datalist id="settings-model-list">
{modelCandidates.map((m) => (
<option
key={m.id}
value={m.id}
label={m.name || m.id}
/>
))}
</datalist>
</label>
<label>
API Key
<input
type="password"
placeholder={form.id ? "(unchanged if empty)" : "sk-..."}
value={form.apiKey}
onChange={(e) =>
setForm((p) => ({ ...p, apiKey: e.target.value }))
}
/>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input
type="checkbox"
checked={form.useCustomUrl}
onChange={(e) =>
setForm((p) => ({ ...p, useCustomUrl: e.target.checked }))
}
/>
Custom Base URL
</label>
{form.useCustomUrl && (
<label>
Base URL
<input
placeholder="e.g. https://api.openai.com/v1"
value={form.baseUrl}
onChange={(e) =>
setForm((p) => ({ ...p, baseUrl: e.target.value }))
}
/>
</label>
)}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button type="submit">Save</button>
{form.id && (
<>
<button
type="button"
onClick={() => deleteProfile(form.id)}
style={{ color: "#ff6b6b" }}
>
Delete
</button>
<button
type="button"
onClick={() => setForm(emptyForm())}
>
Cancel
</button>
</>
)}
</div>
</form>
</article>
{/* Profiles list */}
<article className="card">
<h3>Model Profiles</h3>
{profiles.length === 0 && <p style={{ opacity: 0.6 }}>No model profiles yet.</p>}
<div style={{ display: "grid", gap: 8 }}>
{profiles.map((profile) => (
<div
key={profile.id}
style={{
border: "1px solid #2d3560",
padding: 10,
borderRadius: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<strong>{profile.provider}/{profile.model}</strong>
<span
style={{
opacity: 0.7,
fontSize: "0.85rem",
color: profile.enabled ? "#6dd0ff" : "#ff6b6b",
}}
>
{profile.enabled ? "enabled" : "disabled"}
</span>
</div>
<div style={{ opacity: 0.7, fontSize: "0.9rem", marginTop: 4 }}>
API Key: {maskedKeyMap.get(profile.id) || "..."}
</div>
{profile.baseUrl && (
<div
style={{ opacity: 0.7, fontSize: "0.9rem", marginTop: 2 }}
>
URL: {profile.baseUrl}
</div>
)}
<div
style={{ marginTop: 6, display: "flex", gap: 6 }}
>
<button type="button" onClick={() => editProfile(profile)}>
Edit
</button>
<button
type="button"
onClick={() => deleteProfile(profile.id)}
style={{ color: "#ff6b6b" }}
>
Delete
</button>
</div>
</div>
))}
</div>
</article>
</div>
{/* ---- Chat Model ---- */}
<article className="card" style={{ marginTop: 16 }}>
<h3>Chat Model</h3>
<p style={{ opacity: 0.75, margin: "4px 0 8px" }}>
Select which model profile to use for the Chat feature.
</p>
<select
value={chatProfileId}
onChange={(e) => handleChatProfileChange(e.target.value)}
style={{ minWidth: 260 }}
>
<option value="">(none selected)</option>
{profiles
.filter((p) => p.enabled)
.map((p) => (
<option key={p.id} value={p.id}>
{p.provider}/{p.model}
</option>
))}
</select>
</article>
{message && (
<p style={{ marginTop: 12 }}>{message}</p>
)}
</section>
);
}

114
src/styles.css Normal file
View File

@@ -0,0 +1,114 @@
:root {
--bg: #0f1220;
--panel: #171b2f;
--text: #e6ebff;
--accent: #6dd0ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, sans-serif;
color: var(--text);
background: linear-gradient(120deg, #0f1220, #151935);
}
.app-shell {
display: flex;
height: 100vh;
}
.sidebar {
width: 200px;
min-width: 200px;
background: var(--panel);
border-right: 1px solid #29325a;
display: flex;
flex-direction: column;
padding: 16px 0;
}
.sidebar h1 {
font-size: 1.2rem;
padding: 0 16px 16px;
margin: 0;
border-bottom: 1px solid #29325a;
}
.sidebar nav {
display: flex;
flex-direction: column;
padding: 8px 0;
flex: 1;
}
.sidebar nav button {
display: block;
width: 100%;
text-align: left;
padding: 10px 16px;
border: none;
border-radius: 0;
background: transparent;
color: var(--text);
font-size: 0.95rem;
cursor: pointer;
}
.sidebar nav button:hover {
background: rgba(109, 208, 255, 0.08);
}
.sidebar nav button.active {
background: rgba(109, 208, 255, 0.14);
color: var(--accent);
border-left: 3px solid var(--accent);
}
.sidebar-divider {
height: 1px;
background: #29325a;
margin: 8px 16px;
}
button {
border: 1px solid #2d3560;
background: #1f2750;
color: var(--text);
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
}
.content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.recipe-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.recipe-card, .card, .history-item { background: var(--panel); padding: 12px; border-radius: 10px; border: 1px solid #29325a; }
.diff-viewer { background: #0b0f20; padding: 12px; border-radius: 8px; overflow: auto; max-height: 260px; }
.param-form label { display: block; margin: 10px 0; }
.param-form input:not([type="checkbox"]), .param-form textarea { width: 100%; }
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.home-layout {
display: flex;
gap: 16px;
height: 100%;
}
.home-main {
flex: 1;
overflow-y: auto;
}
.home-chat {
width: 340px;
min-width: 300px;
display: flex;
flex-direction: column;
border-left: 1px solid #29325a;
padding-left: 16px;
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["src-tauri"]
}

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
server: {
port: 1420,
},
plugins: [react()],
});