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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
42
README.md
Normal file
42
README.md
Normal 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
66
agents.md
Normal 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
564
design.md
Normal 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 是一个"配置方案",包含:
|
||||
- 标题、描述、标签
|
||||
- 需要用户填的参数
|
||||
- 配置补丁模板
|
||||
|
||||
**示例 Recipe:Discord 频道专属人设**
|
||||
|
||||
```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/SSG,SEO 友好 |
|
||||
| 部署 | 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. 实现一个真实 Recipe(Discord 人设)
|
||||
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
47
docs/mvp-checklist.md
Normal 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/全部清空可用
|
||||
279
docs/plans/2026-02-15-clawpal-mvp-design.md
Normal file
279
docs/plans/2026-02-15-clawpal-mvp-design.md
Normal 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. 内置 Recipe(MVP)
|
||||
|
||||
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 能给出至少一条可执行修复动作
|
||||
- 无法修复时给出建议与重试按钮
|
||||
403
docs/plans/2026-02-15-clawpal-mvp-implementation-plan.md
Normal file
403
docs/plans/2026-02-15-clawpal-mvp-implementation-plan.md
Normal 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, ¶ms).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?
|
||||
260
docs/plans/2026-02-16-clawpal-product-redesign.md
Normal file
260
docs/plans/2026-02-16-clawpal-product-redesign.md
Normal 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
|
||||
- 当前使用的模型
|
||||
- 关联的 Channels(Discord#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*
|
||||
1366
docs/plans/2026-02-16-clawpal-redesign-implementation-plan.md
Normal file
1366
docs/plans/2026-02-16-clawpal-redesign-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
181
docs/plans/2026-02-16-model-channel-management-implementation.md
Normal file
181
docs/plans/2026-02-16-model-channel-management-implementation.md
Normal 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.
|
||||
|
||||
@@ -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
12
index.html
Normal 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
1742
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
30
scripts/release.sh
Executable 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
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
23
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
2244
src-tauri/gen/schemas/desktop-schema.json
Normal file
2244
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2244
src-tauri/gen/schemas/macOS-schema.json
Normal file
2244
src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.png
Normal file
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
66
src-tauri/recipes.json
Normal 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
2704
src-tauri/src/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
64
src-tauri/src/config_io.rs
Normal file
64
src-tauri/src/config_io.rs
Normal 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
148
src-tauri/src/doctor.rs
Normal 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(¤t).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
90
src-tauri/src/history.rs
Normal 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
57
src-tauri/src/lib.rs
Normal 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
3
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
clawpal::run();
|
||||
}
|
||||
52
src-tauri/src/models.rs
Normal file
52
src-tauri/src/models.rs
Normal 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
305
src-tauri/src/recipe.rs
Normal 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
26
src-tauri/tauri.conf.json
Normal 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
73
src/App.tsx
Normal 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
130
src/components/Chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/DiffViewer.tsx
Normal file
7
src/components/DiffViewer.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function DiffViewer({ value }: { value: string }) {
|
||||
return (
|
||||
<pre className="diff-viewer">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
92
src/components/ParamForm.tsx
Normal file
92
src/components/ParamForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/RecipeCard.tsx
Normal file
19
src/components/RecipeCard.tsx
Normal 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
67
src/lib/api.ts
Normal 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
129
src/lib/chat.ts
Normal 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
57
src/lib/recipe_catalog.ts
Normal 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
46
src/lib/state.ts
Normal 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
169
src/lib/types.ts
Normal 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
11
src/main.tsx
Normal 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
193
src/pages/Doctor.tsx
Normal 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
69
src/pages/History.tsx
Normal 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
168
src/pages/Home.tsx
Normal 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} · {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
91
src/pages/Install.tsx
Normal 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
68
src/pages/Recipes.tsx
Normal 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
347
src/pages/Settings.tsx
Normal 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
114
src/styles.css
Normal 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
16
tsconfig.json
Normal 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
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 1420,
|
||||
},
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user