- Changed the OpenClaw submodule URL to the official GitHub repository for proper integration. - Implemented automatic gateway token generation on first launch, ensuring seamless authentication for users. - Updated the gateway manager to utilize the generated token for WebSocket connections.
4297 lines
144 KiB
Markdown
4297 lines
144 KiB
Markdown
# ClawX 项目架构与版本大纲
|
||
|
||
> 基于 OpenClaw 的图形化 AI 助手应用
|
||
> 技术栈:Electron + React + TypeScript
|
||
> 代码规范:全部英文注释、
|
||
> 开发规范:每一个模块开发完成后,写好完整单测,在{project}/build_process/目录中,更新proecess.md, 增加当前feature.md文档(保持commit_X_feat.md格式),提交commit。
|
||
> 图形支持语言:与openClaw保持一致
|
||
> 如果有疑问请重新参考当前文档
|
||
|
||
---
|
||
|
||
## 一、项目概述
|
||
|
||
### 1.1 项目定位
|
||
|
||
**ClawX** 是 OpenClaw 的图形化封装层,旨在提供:
|
||
|
||
- 🎯 **零命令行体验** - 通过 GUI 完成所有安装、配置和使用
|
||
- 🎨 **现代化 UI** - 美观、直观的桌面应用界面
|
||
- 📦 **开箱即用** - 预装精选技能包,即刻可用
|
||
- 🖥️ **跨平台** - macOS / Windows / Linux 统一体验
|
||
- 🔄 **无缝集成** - 与 OpenClaw 生态完全兼容
|
||
|
||
### 1.2 目标用户
|
||
|
||
| 用户群体 | 痛点 | ClawX 解决方案 |
|
||
|----------|------|----------------|
|
||
| 非技术用户 | 命令行恐惧 | 可视化安装向导 |
|
||
| 效率追求者 | 配置繁琐 | 一键预设技能包 |
|
||
| 跨平台用户 | 体验不一致 | 统一 UI 设计语言 |
|
||
| AI 尝鲜者 | 门槛高 | 引导式 onboarding |
|
||
|
||
### 1.3 与 OpenClaw 的关系
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ ClawX App │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ Electron Main Process │ │
|
||
│ │ - 窗口管理、系统托盘、自动更新 │ │
|
||
│ │ - Gateway 进程管理 │ │
|
||
│ │ - Node.js 环境检测/安装 │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ React Renderer Process │ │
|
||
│ │ - 现代化 UI 界面 │ │
|
||
│ │ - WebSocket 通信层 │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
└────────────────────────┬────────────────────────────────┘
|
||
│ WebSocket (JSON-RPC)
|
||
▼
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ OpenClaw Gateway (上游) │
|
||
│ - 消息通道管理 (WhatsApp/Telegram/Discord...) │
|
||
│ - AI Agent 运行时 │
|
||
│ - 技能/插件系统 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**核心原则**:
|
||
- ✅ **封装而非 Fork** - 通过 npm 依赖引入 openclaw
|
||
- ✅ **不修改上游** - 所有定制通过配置、插件实现
|
||
- ✅ **版本绑定** - 每个 ClawX 版本绑定特定 openclaw 版本
|
||
- ✅ **CLI 兼容** - 命令行保持 `openclaw` 命令,不引入 `clawx` CLI
|
||
|
||
openclaw project remote: https://github.com/openclaw/openclaw
|
||
### 1.4 CLI 兼容性设计
|
||
|
||
ClawX 是 OpenClaw 的**图形化增强层**,而非替代品。用户可以同时使用 GUI 和 CLI:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ClawX + OpenClaw 共存模式 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 用户交互方式 │
|
||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||
│ │ ClawX GUI │ │ openclaw CLI │ │
|
||
│ │ (图形界面) │ │ (命令行) │ │
|
||
│ │ │ │ │ │
|
||
│ │ • 点击操作 │ │ • openclaw doctor │ │
|
||
│ │ • 可视化配置 │ │ • openclaw plugins │ │
|
||
│ │ • 安装向导 │ │ • openclaw config │ │
|
||
│ │ • 普通用户首选 │ │ • 高级用户/脚本 │ │
|
||
│ └──────────┬──────────┘ └──────────┬──────────┘ │
|
||
│ │ │ │
|
||
│ └────────────┬─────────────┘ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ OpenClaw Gateway │ │
|
||
│ │ (共享同一实例) │ │
|
||
│ └─────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌────────────┴────────────┐ │
|
||
│ ▼ ▼ │
|
||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||
│ │ ~/.openclaw/ │ │ OpenClaw 配置/数据 │ │
|
||
│ │ (共享配置目录) │ │ (技能/插件/会话) │ │
|
||
│ └─────────────────────┘ └─────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### CLI 兼容性原则
|
||
|
||
| 原则 | 说明 |
|
||
|------|------|
|
||
| **命令一致** | 使用 `openclaw` 命令,不引入 `clawx` CLI |
|
||
| **配置共享** | GUI 和 CLI 共享 `~/.openclaw/` 配置目录 |
|
||
| **Gateway 共享** | GUI 和 CLI 连接同一个 Gateway 实例 |
|
||
| **功能互补** | GUI 简化常用操作,CLI 支持高级/自动化场景 |
|
||
|
||
#### 用户使用场景
|
||
|
||
**场景 A: 纯 GUI 用户 (新手)**
|
||
```
|
||
1. 安装 ClawX.app
|
||
2. 通过安装向导完成配置
|
||
3. 日常使用 GUI 界面
|
||
4. 无需接触命令行
|
||
```
|
||
|
||
**场景 B: GUI + CLI 混合用户 (进阶)**
|
||
```
|
||
1. 安装 ClawX.app (自动包含 openclaw CLI)
|
||
2. 日常使用 GUI 界面
|
||
3. 需要时打开终端使用 CLI:
|
||
- openclaw doctor # 健康检查
|
||
- openclaw plugins list # 查看插件
|
||
- openclaw config get # 查看配置
|
||
```
|
||
|
||
**场景 C: CLI 优先用户 (开发者)**
|
||
```
|
||
1. 安装 ClawX.app 或单独安装 openclaw CLI
|
||
2. 主要使用命令行操作
|
||
3. 偶尔打开 GUI 查看状态或配置复杂选项
|
||
```
|
||
|
||
#### ClawX 安装时的 CLI 处理
|
||
|
||
```typescript
|
||
// electron/installer/cli-setup.ts
|
||
|
||
/**
|
||
* ClawX 安装时确保 openclaw CLI 可用
|
||
* 不创建 clawx 命令,保持与上游一致
|
||
*/
|
||
export async function ensureOpenClawCli(): Promise<void> {
|
||
const isInstalled = await checkCliInstalled('openclaw');
|
||
|
||
if (!isInstalled) {
|
||
// 通过 npm 全局安装 openclaw
|
||
await privilegeManager.execAsAdmin(
|
||
'npm install -g openclaw',
|
||
{ reason: '安装 OpenClaw 命令行工具' }
|
||
);
|
||
}
|
||
|
||
// 确保 PATH 包含 npm 全局目录
|
||
await pathManager.ensureNpmGlobalPath();
|
||
|
||
// 验证安装
|
||
const version = await exec('openclaw --version');
|
||
console.log(`OpenClaw CLI installed: ${version}`);
|
||
}
|
||
|
||
/**
|
||
* ClawX 不创建自己的 CLI 命令
|
||
* 所有命令行操作都通过 openclaw 完成
|
||
*/
|
||
// ❌ 不会有 clawx CLI
|
||
// ✅ 只有 openclaw CLI
|
||
```
|
||
|
||
#### GUI 与 CLI 的功能映射
|
||
|
||
| 操作 | ClawX GUI | openclaw CLI |
|
||
|------|-----------|--------------|
|
||
| 健康检查 | 设置 → 诊断 → 运行检查 | `openclaw doctor` |
|
||
| 安装插件 | 技能市场 → 安装 | `openclaw plugins install <name>` |
|
||
| 查看配置 | 设置 → 高级 | `openclaw config get` |
|
||
| 修改配置 | 设置页面表单 | `openclaw config set <key> <value>` |
|
||
| 查看状态 | Dashboard | `openclaw status` |
|
||
| 查看日志 | 设置 → 日志 | `openclaw logs` |
|
||
| 连接通道 | 通道 → 添加 → 扫码 | `openclaw channels add whatsapp` |
|
||
| 发送消息 | 对话界面 | `openclaw message send <target> <msg>` |
|
||
|
||
#### 开发者模式: 终端集成
|
||
|
||
```typescript
|
||
// src/pages/Settings/DeveloperSettings.tsx
|
||
|
||
export function DeveloperSettings() {
|
||
const openTerminal = async () => {
|
||
// 在内置终端或系统终端中打开,预设好环境
|
||
await window.electron.ipcRenderer.invoke('terminal:open', {
|
||
cwd: '~/.openclaw',
|
||
env: {
|
||
// 确保 openclaw 命令可用
|
||
PATH: `${process.env.PATH}:${npmGlobalBinPath}`,
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>开发者工具</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 快捷命令按钮 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button variant="outline" onClick={() => runCommand('openclaw doctor')}>
|
||
<Terminal className="w-4 h-4 mr-2" />
|
||
运行健康检查
|
||
</Button>
|
||
<Button variant="outline" onClick={() => runCommand('openclaw status --all')}>
|
||
<Activity className="w-4 h-4 mr-2" />
|
||
查看完整状态
|
||
</Button>
|
||
<Button variant="outline" onClick={() => runCommand('openclaw plugins list')}>
|
||
<Puzzle className="w-4 h-4 mr-2" />
|
||
列出插件
|
||
</Button>
|
||
<Button variant="outline" onClick={() => runCommand('openclaw config get')}>
|
||
<Settings className="w-4 h-4 mr-2" />
|
||
查看配置
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 打开终端 */}
|
||
<Button variant="outline" className="w-full" onClick={openTerminal}>
|
||
<Terminal className="w-4 h-4 mr-2" />
|
||
打开终端 (已配置 openclaw 环境)
|
||
</Button>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
ClawX 与 OpenClaw CLI 完全兼容,您可以在终端中使用 <code>openclaw</code> 命令进行高级操作。
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 配置同步机制
|
||
|
||
```typescript
|
||
// electron/config/sync.ts
|
||
|
||
/**
|
||
* ClawX 和 openclaw CLI 共享同一配置文件
|
||
* 任何一方的修改都会实时反映到另一方
|
||
*/
|
||
export class ConfigSync {
|
||
private configPath = join(homedir(), '.openclaw', 'config.json');
|
||
private watcher: FSWatcher | null = null;
|
||
|
||
/**
|
||
* 监听配置文件变化 (CLI 修改时同步到 GUI)
|
||
*/
|
||
startWatching(): void {
|
||
this.watcher = watch(this.configPath, async () => {
|
||
const config = await this.readConfig();
|
||
// 通知渲染进程配置已更新
|
||
mainWindow?.webContents.send('config:updated', config);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* GUI 修改配置时,写入共享配置文件 (CLI 可读取)
|
||
*/
|
||
async updateConfig(updates: Partial<OpenClawConfig>): Promise<void> {
|
||
const current = await this.readConfig();
|
||
const merged = deepMerge(current, updates);
|
||
await writeFile(this.configPath, JSON.stringify(merged, null, 2));
|
||
}
|
||
|
||
/**
|
||
* 配置文件位置说明
|
||
*
|
||
* ~/.openclaw/
|
||
* ├── config.json # 主配置 (GUI 和 CLI 共享)
|
||
* ├── credentials/ # 凭证存储
|
||
* ├── sessions/ # 会话数据
|
||
* └── skills/ # 用户技能
|
||
*
|
||
* ~/.clawx/
|
||
* ├── presets.json # ClawX 专属配置 (技能包选择等)
|
||
* └── ui-state.json # GUI 状态 (窗口位置等)
|
||
*/
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二、技术架构
|
||
|
||
### 2.1 技术栈选型
|
||
|
||
| 层级 | 技术 | 版本 | 选型理由 |
|
||
|------|------|------|----------|
|
||
| **运行时** | Electron | 33+ | 跨平台、嵌入 Node.js |
|
||
| **前端框架** | React | 19 | 生态丰富、hooks 模式 |
|
||
| **UI 组件** | shadcn/ui | latest | 可定制、现代设计 |
|
||
| **样式** | Tailwind CSS | 4.x | 原子化、快速开发 |
|
||
| **状态管理** | Zustand | 5.x | 轻量、TypeScript 友好 |
|
||
| **路由** | React Router | 7.x | 声明式、嵌套路由 |
|
||
| **构建工具** | Vite | 6.x | 极速 HMR |
|
||
| **打包工具** | electron-builder | latest | 多平台打包、自动更新 |
|
||
| **测试** | Vitest + Playwright | latest | 单元测试 + E2E |
|
||
| **语言** | TypeScript | 5.x | 类型安全 |
|
||
|
||
### 2.2 双端口架构与开发者模式
|
||
|
||
ClawX 采用**双端口分层架构**,区分普通用户界面与开发者管理后台:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 用户访问入口 │
|
||
├─────────────────────────────┬───────────────────────────────────┤
|
||
│ │ │
|
||
│ ┌─────────────────────┐ │ ┌─────────────────────────┐ │
|
||
│ │ ClawX GUI │ │ │ OpenClaw Control UI │ │
|
||
│ │ Port: 23333 │ │ │ Port: 18789 │ │
|
||
│ │ │ │ │ │ │
|
||
│ │ 🎨 现代化界面 │ │ │ ⚙️ 原生管理后台 │ │
|
||
│ │ 📦 预装技能包 │──▶│ │ 🔧 高级配置 │ │
|
||
│ │ 🚀 一键安装向导 │ │ │ 📊 调试日志 │ │
|
||
│ │ 👤 普通用户 │ │ │ 👨💻 开发者/高级用户 │ │
|
||
│ └─────────────────────┘ │ └─────────────────────────┘ │
|
||
│ │ │
|
||
│ [开发者模式] ────────┼──────────────▶ │
|
||
│ │ │
|
||
└─────────────────────────────┴───────────────────────────────────┘
|
||
│
|
||
│ WebSocket (JSON-RPC)
|
||
▼
|
||
┌────────────────────────┐
|
||
│ OpenClaw Gateway │
|
||
│ 内部服务端口: 18789 │
|
||
└────────────────────────┘
|
||
```
|
||
|
||
#### 端口分配
|
||
|
||
| 端口 | 服务 | 用途 | 目标用户 |
|
||
|------|------|------|----------|
|
||
| **23333** | ClawX GUI | 默认图形化界面 | 所有用户 |
|
||
| **18789** | OpenClaw Control UI | Gateway 管理后台 | 开发者/高级用户 |
|
||
|
||
> **端口选择说明**:
|
||
> - `23333` - ClawX 专属端口,易记且不与常见服务冲突
|
||
> - `18789` - OpenClaw 原生端口,保持上游兼容
|
||
|
||
#### 开发者模式入口
|
||
|
||
```typescript
|
||
// src/components/layout/Sidebar.tsx
|
||
export function Sidebar() {
|
||
const [devModeClicks, setDevModeClicks] = useState(0);
|
||
|
||
// 连续点击 5 次版本号解锁开发者模式
|
||
const handleVersionClick = () => {
|
||
const clicks = devModeClicks + 1;
|
||
setDevModeClicks(clicks);
|
||
|
||
if (clicks >= 5) {
|
||
toast.success('开发者模式已解锁');
|
||
setDevModeClicks(0);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<aside className="w-64 border-r flex flex-col h-full">
|
||
{/* 导航菜单 */}
|
||
<nav className="flex-1 p-4 space-y-1">
|
||
<NavItem href="/" icon={<Home />} label="概览" />
|
||
<NavItem href="/chat" icon={<MessageSquare />} label="对话" />
|
||
<NavItem href="/channels" icon={<Radio />} label="通道" />
|
||
<NavItem href="/skills" icon={<Puzzle />} label="技能" />
|
||
<NavItem href="/cron" icon={<Clock />} label="定时任务" />
|
||
<NavItem href="/settings" icon={<Settings />} label="设置" />
|
||
</nav>
|
||
|
||
{/* 底部版本信息 */}
|
||
<footer className="p-4 border-t">
|
||
<button onClick={handleVersionClick} className="text-xs text-muted">
|
||
ClawX v1.0.0
|
||
</button>
|
||
|
||
{/* 开发者模式入口 - 解锁后显示 */}
|
||
{isDevModeUnlocked && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => window.electron.openExternal('http://localhost:18789')}
|
||
>
|
||
<Terminal className="w-4 h-4 mr-2" />
|
||
开发者模式
|
||
</Button>
|
||
)}
|
||
</footer>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 设置页面快捷入口
|
||
|
||
```typescript
|
||
// src/pages/Settings/AdvancedSettings.tsx
|
||
export function AdvancedSettings() {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>高级设置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{/* 其他设置项 */}
|
||
|
||
<Separator />
|
||
|
||
{/* 开发者工具区域 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">开发者工具</Label>
|
||
<p className="text-xs text-muted-foreground">
|
||
访问 OpenClaw 原生管理后台,进行高级配置和调试
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => window.electron.openExternal('http://localhost:18789')}
|
||
>
|
||
<ExternalLink className="w-4 h-4 mr-2" />
|
||
打开 OpenClaw 管理后台
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">
|
||
将在浏览器中打开 http://localhost:18789
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 配置文件
|
||
|
||
```typescript
|
||
// electron/utils/config.ts
|
||
export const PORTS = {
|
||
/** ClawX 默认 GUI 端口 */
|
||
CLAWX_GUI: 23333,
|
||
|
||
/** OpenClaw Gateway 端口 (上游默认) */
|
||
OPENCLAW_GATEWAY: 18789,
|
||
} as const;
|
||
|
||
// 环境变量覆盖
|
||
export function getPort(key: keyof typeof PORTS): number {
|
||
const envKey = `CLAWX_PORT_${key}`;
|
||
const envValue = process.env[envKey];
|
||
return envValue ? parseInt(envValue, 10) : PORTS[key];
|
||
}
|
||
```
|
||
|
||
#### 使用场景对比
|
||
|
||
| 场景 | ClawX GUI (23333) | OpenClaw 后台 (18789) |
|
||
|------|-------------------|----------------------|
|
||
| 日常对话 | ✅ | ❌ |
|
||
| 查看消息记录 | ✅ | ✅ |
|
||
| 添加/管理通道 | ✅ (简化版) | ✅ (完整版) |
|
||
| 安装技能包 | ✅ | ❌ |
|
||
| 编辑技能配置 | ❌ | ✅ |
|
||
| 查看原始日志 | ❌ | ✅ |
|
||
| 插件管理 | ❌ | ✅ |
|
||
| JSON 配置编辑 | ❌ | ✅ |
|
||
| WebSocket 调试 | ❌ | ✅ |
|
||
| Cron 任务管理 | ✅ | ✅ |
|
||
|
||
### 2.3 目录结构
|
||
|
||
```
|
||
clawx/
|
||
├── .github/
|
||
│ ├── workflows/
|
||
│ │ ├── ci.yml # 持续集成
|
||
│ │ ├── release.yml # 自动发布
|
||
│ │ └── test.yml # 测试流水线
|
||
│ └── ISSUE_TEMPLATE/
|
||
│
|
||
├── electron/ # Electron 主进程
|
||
│ ├── main/
|
||
│ │ ├── index.ts # 主入口
|
||
│ │ ├── window.ts # 窗口管理
|
||
│ │ ├── tray.ts # 系统托盘
|
||
│ │ ├── menu.ts # 原生菜单
|
||
│ │ └── ipc-handlers.ts # IPC 处理器
|
||
│ │
|
||
│ ├── updater/ # 自动更新模块
|
||
│ │ ├── index.ts # 更新管理器入口
|
||
│ │ ├── checker.ts # 版本检查器
|
||
│ │ ├── downloader.ts # 下载管理
|
||
│ │ ├── notifier.ts # 更新通知
|
||
│ │ └── channels.ts # 更新通道配置
|
||
│ │
|
||
│ ├── gateway/
|
||
│ │ ├── manager.ts # Gateway 进程生命周期
|
||
│ │ ├── client.ts # WebSocket 客户端
|
||
│ │ ├── protocol.ts # JSON-RPC 协议定义
|
||
│ │ └── health.ts # 健康检查
|
||
│ │
|
||
│ ├── installer/
|
||
│ │ ├── node-installer.ts # Node.js 自动安装
|
||
│ │ ├── openclaw-installer.ts # openclaw npm 安装
|
||
│ │ ├── skill-installer.ts # 技能包安装
|
||
│ │ └── platform/
|
||
│ │ ├── darwin.ts # macOS 特定逻辑
|
||
│ │ ├── win32.ts # Windows 特定逻辑
|
||
│ │ └── linux.ts # Linux 特定逻辑
|
||
│ │
|
||
│ ├── privilege/ # 权限提升模块
|
||
│ │ ├── index.ts # 统一权限管理器
|
||
│ │ ├── darwin-admin.ts # macOS 管理员权限 (osascript)
|
||
│ │ ├── win32-admin.ts # Windows UAC 提升
|
||
│ │ └── linux-admin.ts # Linux pkexec/polkit
|
||
│ │
|
||
│ ├── env-config/ # 环境配置模块
|
||
│ │ ├── index.ts # 环境配置管理器
|
||
│ │ ├── path-manager.ts # PATH 环境变量管理
|
||
│ │ ├── api-keys.ts # API Key 安全存储
|
||
│ │ └── shell-profile.ts # Shell 配置文件管理
|
||
│ │
|
||
│ ├── preload/
|
||
│ │ └── index.ts # 预加载脚本 (contextBridge)
|
||
│ │
|
||
│ └── utils/
|
||
│ ├── logger.ts # 日志
|
||
│ ├── paths.ts # 路径管理
|
||
│ └── store.ts # 持久化存储 (electron-store)
|
||
│
|
||
├── src/ # React 渲染进程
|
||
│ ├── main.tsx # React 入口
|
||
│ ├── App.tsx # 根组件
|
||
│ │
|
||
│ ├── pages/ # 页面组件
|
||
│ │ ├── Dashboard/
|
||
│ │ │ ├── index.tsx
|
||
│ │ │ ├── StatusCard.tsx
|
||
│ │ │ └── QuickActions.tsx
|
||
│ │ ├── Chat/
|
||
│ │ │ ├── index.tsx
|
||
│ │ │ ├── MessageList.tsx
|
||
│ │ │ ├── InputArea.tsx
|
||
│ │ │ └── ToolCallCard.tsx
|
||
│ │ ├── Channels/
|
||
│ │ │ ├── index.tsx
|
||
│ │ │ ├── ChannelCard.tsx
|
||
│ │ │ └── QRScanner.tsx
|
||
│ │ ├── Skills/
|
||
│ │ │ ├── index.tsx
|
||
│ │ │ ├── SkillCard.tsx
|
||
│ │ │ ├── SkillMarket.tsx
|
||
│ │ │ └── BundleSelector.tsx
|
||
│ │ ├── Cron/ # 定时任务
|
||
│ │ │ ├── index.tsx # 任务列表页
|
||
│ │ │ ├── CronJobCard.tsx # 任务卡片组件
|
||
│ │ │ ├── CronEditor.tsx # 任务编辑器
|
||
│ │ │ ├── CronSchedulePicker.tsx # Cron 表达式选择器
|
||
│ │ │ └── CronHistory.tsx # 执行历史
|
||
│ │ ├── Settings/
|
||
│ │ │ ├── index.tsx
|
||
│ │ │ ├── GeneralSettings.tsx
|
||
│ │ │ ├── ProviderSettings.tsx
|
||
│ │ │ ├── ChannelsSettings.tsx # 通道连接配置 (从安装向导移出)
|
||
│ │ │ └── AdvancedSettings.tsx
|
||
│ │ └── Setup/ # 安装向导 (简化版,不含通道连接)
|
||
│ │ ├── index.tsx
|
||
│ │ ├── WelcomeStep.tsx
|
||
│ │ ├── RuntimeStep.tsx
|
||
│ │ ├── ProviderStep.tsx
|
||
│ │ └── SkillStep.tsx
|
||
│ │
|
||
│ ├── components/ # 通用组件
|
||
│ │ ├── ui/ # shadcn/ui 组件
|
||
│ │ │ ├── button.tsx
|
||
│ │ │ ├── card.tsx
|
||
│ │ │ ├── dialog.tsx
|
||
│ │ │ └── ...
|
||
│ │ ├── layout/
|
||
│ │ │ ├── Sidebar.tsx
|
||
│ │ │ ├── Header.tsx
|
||
│ │ │ └── MainLayout.tsx
|
||
│ │ └── common/
|
||
│ │ ├── LoadingSpinner.tsx
|
||
│ │ ├── ErrorBoundary.tsx
|
||
│ │ └── StatusBadge.tsx
|
||
│ │
|
||
│ ├── hooks/ # 自定义 Hooks
|
||
│ │ ├── useGateway.ts # Gateway 连接状态
|
||
│ │ ├── useChannels.ts # 通道数据
|
||
│ │ ├── useSkills.ts # 技能数据
|
||
│ │ ├── useChat.ts # 聊天会话
|
||
│ │ ├── useCron.ts # 定时任务数据
|
||
│ │ └── useElectron.ts # IPC 通信
|
||
│ │
|
||
│ ├── stores/ # Zustand 状态管理
|
||
│ │ ├── gateway.ts # Gateway 状态
|
||
│ │ ├── channels.ts # 通道状态
|
||
│ │ ├── chat.ts # 聊天状态
|
||
│ │ ├── skills.ts # 技能状态
|
||
│ │ ├── cron.ts # 定时任务状态
|
||
│ │ └── settings.ts # 设置状态
|
||
│ │
|
||
│ ├── services/ # 服务层
|
||
│ │ ├── gateway-rpc.ts # Gateway RPC 调用封装
|
||
│ │ ├── skill-service.ts # 技能服务
|
||
│ │ ├── cron-service.ts # 定时任务服务
|
||
│ │ └── update-service.ts # 更新服务
|
||
│ │
|
||
│ ├── types/ # TypeScript 类型定义
|
||
│ │ ├── gateway.ts # Gateway 协议类型
|
||
│ │ ├── channel.ts # 通道类型
|
||
│ │ ├── skill.ts # 技能类型
|
||
│ │ ├── cron.ts # 定时任务类型
|
||
│ │ └── electron.d.ts # Electron API 类型
|
||
│ │
|
||
│ ├── utils/ # 工具函数
|
||
│ │ ├── format.ts
|
||
│ │ └── platform.ts
|
||
│ │
|
||
│ └── styles/
|
||
│ ├── globals.css # 全局样式
|
||
│ └── themes/ # 主题定义
|
||
│ ├── light.css
|
||
│ └── dark.css
|
||
│
|
||
├── resources/ # 静态资源
|
||
│ ├── icons/ # 应用图标
|
||
│ │ ├── icon.icns # macOS
|
||
│ │ ├── icon.ico # Windows
|
||
│ │ └── icon.png # Linux
|
||
│ ├── skills/ # 预装技能包
|
||
│ │ ├── productivity.json
|
||
│ │ ├── developer.json
|
||
│ │ └── smart-home.json
|
||
│ └── locales/ # 国际化 (可选)
|
||
│ ├── en.json
|
||
│ └── zh-CN.json
|
||
│
|
||
├── scripts/ # 构建/工具脚本
|
||
│ ├── build.ts # 构建脚本
|
||
│ ├── release.ts # 发布脚本
|
||
│ ├── notarize.ts # macOS 公证
|
||
│ └── dev.ts # 开发脚本
|
||
│
|
||
├── tests/ # 测试
|
||
│ ├── unit/ # 单元测试
|
||
│ ├── integration/ # 集成测试
|
||
│ └── e2e/ # E2E 测试
|
||
│ ├── setup.spec.ts # 安装向导测试
|
||
│ ├── chat.spec.ts # 聊天功能测试
|
||
│ └── channels.spec.ts # 通道配置测试
|
||
│
|
||
├── .env.example # 环境变量示例
|
||
├── .eslintrc.cjs # ESLint 配置
|
||
├── .prettierrc # Prettier 配置
|
||
├── electron-builder.yml # 打包配置
|
||
├── package.json
|
||
├── tsconfig.json # TypeScript 配置 (主)
|
||
├── tsconfig.node.json # TypeScript 配置 (Node)
|
||
├── vite.config.ts # Vite 配置
|
||
├── tailwind.config.js # Tailwind 配置
|
||
├── CHANGELOG.md
|
||
├── LICENSE
|
||
└── README.md
|
||
```
|
||
|
||
### 2.4 核心模块设计
|
||
|
||
#### 2.4.1 Gateway 管理器
|
||
|
||
```typescript
|
||
// electron/gateway/manager.ts
|
||
import { spawn, ChildProcess } from 'child_process';
|
||
import { EventEmitter } from 'events';
|
||
import WebSocket from 'ws';
|
||
|
||
export interface GatewayStatus {
|
||
state: 'stopped' | 'starting' | 'running' | 'error';
|
||
port: number;
|
||
pid?: number;
|
||
uptime?: number;
|
||
error?: string;
|
||
}
|
||
|
||
export class GatewayManager extends EventEmitter {
|
||
private process: ChildProcess | null = null;
|
||
private ws: WebSocket | null = null;
|
||
private status: GatewayStatus = { state: 'stopped', port: 18789 };
|
||
|
||
// 启动 Gateway
|
||
async start(): Promise<void> {
|
||
if (this.status.state === 'running') return;
|
||
|
||
this.setStatus({ state: 'starting' });
|
||
|
||
try {
|
||
// 检查已有进程
|
||
const existing = await this.findExisting();
|
||
if (existing) {
|
||
await this.connect(existing.port);
|
||
return;
|
||
}
|
||
|
||
// 启动新进程
|
||
this.process = spawn('openclaw', [
|
||
'gateway', 'run',
|
||
'--port', String(this.status.port),
|
||
'--force'
|
||
], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
detached: true,
|
||
});
|
||
|
||
this.process.on('exit', (code) => {
|
||
this.setStatus({ state: 'stopped' });
|
||
this.emit('exit', code);
|
||
});
|
||
|
||
// 等待就绪并连接
|
||
await this.waitForReady();
|
||
await this.connect(this.status.port);
|
||
|
||
} catch (error) {
|
||
this.setStatus({ state: 'error', error: String(error) });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 停止 Gateway
|
||
async stop(): Promise<void> {
|
||
this.ws?.close();
|
||
this.process?.kill();
|
||
this.setStatus({ state: 'stopped' });
|
||
}
|
||
|
||
// RPC 调用
|
||
async rpc<T>(method: string, params?: unknown): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||
reject(new Error('Gateway not connected'));
|
||
return;
|
||
}
|
||
|
||
const id = crypto.randomUUID();
|
||
const handler = (data: WebSocket.Data) => {
|
||
const msg = JSON.parse(data.toString());
|
||
if (msg.id === id) {
|
||
this.ws?.off('message', handler);
|
||
if (msg.error) reject(msg.error);
|
||
else resolve(msg.result as T);
|
||
}
|
||
};
|
||
|
||
this.ws.on('message', handler);
|
||
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
||
});
|
||
}
|
||
|
||
private setStatus(update: Partial<GatewayStatus>): void {
|
||
this.status = { ...this.status, ...update };
|
||
this.emit('status', this.status);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.4.2 安装向导流程
|
||
|
||
```typescript
|
||
// src/pages/Setup/index.tsx
|
||
import { useState } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
||
interface SetupStep {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
component: React.ComponentType<StepProps>;
|
||
}
|
||
|
||
const steps: SetupStep[] = [
|
||
{
|
||
id: 'welcome',
|
||
title: '欢迎使用 ClawX',
|
||
description: '您的 AI 助手,即将启程',
|
||
component: WelcomeStep,
|
||
},
|
||
{
|
||
id: 'runtime',
|
||
title: '环境检测',
|
||
description: '检测并安装必要运行环境',
|
||
component: RuntimeStep,
|
||
},
|
||
{
|
||
id: 'provider',
|
||
title: '选择 AI 模型',
|
||
description: '配置您的 AI 服务提供商',
|
||
component: ProviderStep,
|
||
},
|
||
// NOTE: Channel step removed - 通道连接移至 Settings > Channels 页面
|
||
// 用户可在完成初始设置后自行配置消息通道
|
||
// NOTE: Skills selection step removed - 自动安装必要组件
|
||
// 用户无需手动选择,核心组件自动安装
|
||
{
|
||
id: 'installing',
|
||
title: '安装组件',
|
||
description: '正在安装必要的 AI 组件',
|
||
component: InstallingStep,
|
||
},
|
||
{
|
||
id: 'complete',
|
||
title: '设置完成',
|
||
description: '一切就绪,开始使用吧!',
|
||
component: CompleteStep,
|
||
},
|
||
];
|
||
|
||
export function SetupWizard() {
|
||
const [currentStep, setCurrentStep] = useState(0);
|
||
const [setupData, setSetupData] = useState<SetupData>({});
|
||
|
||
const step = steps[currentStep];
|
||
const StepComponent = step.component;
|
||
|
||
const handleNext = (data: Partial<SetupData>) => {
|
||
setSetupData({ ...setupData, ...data });
|
||
setCurrentStep((i) => Math.min(i + 1, steps.length - 1));
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
|
||
{/* 进度指示器 */}
|
||
<div className="flex justify-center pt-8">
|
||
{steps.map((s, i) => (
|
||
<div key={s.id} className="flex items-center">
|
||
<div className={cn(
|
||
'w-3 h-3 rounded-full transition-colors',
|
||
i <= currentStep ? 'bg-blue-500' : 'bg-slate-600'
|
||
)} />
|
||
{i < steps.length - 1 && (
|
||
<div className={cn(
|
||
'w-12 h-0.5 transition-colors',
|
||
i < currentStep ? 'bg-blue-500' : 'bg-slate-600'
|
||
)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 步骤内容 */}
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={step.id}
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
className="max-w-2xl mx-auto p-8"
|
||
>
|
||
<h1 className="text-3xl font-bold text-white mb-2">{step.title}</h1>
|
||
<p className="text-slate-400 mb-8">{step.description}</p>
|
||
|
||
<StepComponent
|
||
data={setupData}
|
||
onNext={handleNext}
|
||
onBack={() => setCurrentStep((i) => Math.max(i - 1, 0))}
|
||
/>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 2.4.3 预装技能包定义
|
||
|
||
```typescript
|
||
// resources/skills/bundles.ts
|
||
export interface SkillBundle {
|
||
id: string;
|
||
name: string;
|
||
nameZh: string;
|
||
description: string;
|
||
descriptionZh: string;
|
||
icon: string;
|
||
skills: string[];
|
||
recommended?: boolean;
|
||
}
|
||
|
||
export const skillBundles: SkillBundle[] = [
|
||
{
|
||
id: 'productivity',
|
||
name: 'Productivity',
|
||
nameZh: '效率办公',
|
||
description: 'Calendar, reminders, notes, email management',
|
||
descriptionZh: '日历、提醒、笔记、邮件管理',
|
||
icon: '📋',
|
||
skills: [
|
||
'apple-reminders',
|
||
'apple-notes',
|
||
'himalaya',
|
||
'notion',
|
||
'obsidian',
|
||
'trello',
|
||
],
|
||
recommended: true,
|
||
},
|
||
{
|
||
id: 'developer',
|
||
name: 'Developer Tools',
|
||
nameZh: '开发者工具',
|
||
description: 'GitHub, coding assistant, terminal management',
|
||
descriptionZh: 'GitHub、代码助手、终端管理',
|
||
icon: '💻',
|
||
skills: [
|
||
'github',
|
||
'coding-agent',
|
||
'tmux',
|
||
],
|
||
recommended: true,
|
||
},
|
||
{
|
||
id: 'smart-home',
|
||
name: 'Smart Home',
|
||
nameZh: '智能家居',
|
||
description: 'Lights, music, device control',
|
||
descriptionZh: '灯光、音乐、设备控制',
|
||
icon: '🏠',
|
||
skills: [
|
||
'openhue',
|
||
'sonoscli',
|
||
'spotify-player',
|
||
],
|
||
},
|
||
{
|
||
id: 'media',
|
||
name: 'Media & Creative',
|
||
nameZh: '多媒体创作',
|
||
description: 'Image generation, video processing, audio transcription',
|
||
descriptionZh: '图片生成、视频处理、音频转写',
|
||
icon: '🎨',
|
||
skills: [
|
||
'openai-image-gen',
|
||
'nano-banana-pro',
|
||
'video-frames',
|
||
'openai-whisper-api',
|
||
],
|
||
},
|
||
{
|
||
id: 'communication',
|
||
name: 'Communication',
|
||
nameZh: '通讯增强',
|
||
description: 'Messaging, voice calls, notifications',
|
||
descriptionZh: '消息管理、语音通话、通知',
|
||
icon: '💬',
|
||
skills: [
|
||
'discord',
|
||
'slack',
|
||
'voice-call',
|
||
'imsg',
|
||
],
|
||
},
|
||
{
|
||
id: 'security',
|
||
name: 'Security & Privacy',
|
||
nameZh: '安全隐私',
|
||
description: 'Password management, secrets',
|
||
descriptionZh: '密码管理、密钥存储',
|
||
icon: '🔐',
|
||
skills: [
|
||
'1password',
|
||
],
|
||
},
|
||
{
|
||
id: 'information',
|
||
name: 'Information',
|
||
nameZh: '信息获取',
|
||
description: 'Weather, news, web browsing',
|
||
descriptionZh: '天气、新闻、网页浏览',
|
||
icon: '🌐',
|
||
skills: [
|
||
'weather',
|
||
'blogwatcher',
|
||
'summarize',
|
||
],
|
||
},
|
||
];
|
||
```
|
||
|
||
### 2.5 默认预装机制设计
|
||
|
||
ClawX 需要在代码层面实现**默认技能**和**默认扩展**的预装机制,确保用户开箱即用。
|
||
|
||
#### 2.5.1 预装配置定义
|
||
|
||
```typescript
|
||
// electron/presets/defaults.ts
|
||
|
||
/**
|
||
* ClawX 预装配置
|
||
* 定义首次安装时默认启用的技能和扩展
|
||
*/
|
||
export interface ClawXPresets {
|
||
/** 默认启用的技能 ID 列表 */
|
||
skills: string[];
|
||
|
||
/** 默认启用的扩展 ID 列表 */
|
||
extensions: string[];
|
||
|
||
/** 默认技能包 (用户可在安装向导中选择) */
|
||
defaultBundles: string[];
|
||
|
||
/** 核心技能 (始终启用,用户不可禁用) */
|
||
coreSkills: string[];
|
||
|
||
/** 核心扩展 (始终启用,用户不可禁用) */
|
||
coreExtensions: string[];
|
||
}
|
||
|
||
/**
|
||
* ClawX 默认预装配置
|
||
*/
|
||
export const CLAWX_PRESETS: ClawXPresets = {
|
||
// 默认启用的技能 (首次安装自动启用)
|
||
skills: [
|
||
// Tier 1: 核心体验技能
|
||
'coding-agent', // 代码助手 (类似 opencode)
|
||
'canvas', // Canvas UI
|
||
'summarize', // 内容摘要
|
||
|
||
// Tier 2: 常用工具技能
|
||
'weather', // 天气查询
|
||
'github', // GitHub 集成
|
||
'clawhub', // 技能市场
|
||
],
|
||
|
||
// 默认启用的扩展
|
||
extensions: [
|
||
'lobster', // UI 美化
|
||
'memory-core', // 记忆系统
|
||
],
|
||
|
||
// 默认推荐的技能包 (安装向导中预选)
|
||
defaultBundles: [
|
||
'productivity',
|
||
'developer',
|
||
],
|
||
|
||
// 核心技能 (不可禁用)
|
||
coreSkills: [
|
||
'coding-agent', // 代码能力是核心体验
|
||
],
|
||
|
||
// 核心扩展 (不可禁用)
|
||
coreExtensions: [
|
||
'memory-core', // 记忆是核心功能
|
||
],
|
||
};
|
||
```
|
||
|
||
#### 2.5.2 预装加载器
|
||
|
||
```typescript
|
||
// electron/installer/preset-loader.ts
|
||
|
||
import { CLAWX_PRESETS } from '../presets/defaults';
|
||
import { GatewayManager } from '../gateway/manager';
|
||
|
||
export interface PresetLoadResult {
|
||
skills: { id: string; status: 'loaded' | 'failed'; error?: string }[];
|
||
extensions: { id: string; status: 'loaded' | 'failed'; error?: string }[];
|
||
}
|
||
|
||
export class PresetLoader {
|
||
constructor(private gateway: GatewayManager) {}
|
||
|
||
/**
|
||
* 首次安装时加载所有预装项
|
||
*/
|
||
async loadInitialPresets(): Promise<PresetLoadResult> {
|
||
const result: PresetLoadResult = { skills: [], extensions: [] };
|
||
|
||
// 1. 加载核心扩展 (优先级最高)
|
||
for (const extId of CLAWX_PRESETS.coreExtensions) {
|
||
const status = await this.loadExtension(extId, { required: true });
|
||
result.extensions.push({ id: extId, ...status });
|
||
}
|
||
|
||
// 2. 加载默认扩展
|
||
for (const extId of CLAWX_PRESETS.extensions) {
|
||
if (!CLAWX_PRESETS.coreExtensions.includes(extId)) {
|
||
const status = await this.loadExtension(extId, { required: false });
|
||
result.extensions.push({ id: extId, ...status });
|
||
}
|
||
}
|
||
|
||
// 3. 加载核心技能
|
||
for (const skillId of CLAWX_PRESETS.coreSkills) {
|
||
const status = await this.loadSkill(skillId, { required: true });
|
||
result.skills.push({ id: skillId, ...status });
|
||
}
|
||
|
||
// 4. 加载默认技能
|
||
for (const skillId of CLAWX_PRESETS.skills) {
|
||
if (!CLAWX_PRESETS.coreSkills.includes(skillId)) {
|
||
const status = await this.loadSkill(skillId, { required: false });
|
||
result.skills.push({ id: skillId, ...status });
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 加载用户选择的技能包
|
||
*/
|
||
async loadBundles(bundleIds: string[]): Promise<void> {
|
||
const { skillBundles } = await import('../../resources/skills/bundles');
|
||
|
||
for (const bundleId of bundleIds) {
|
||
const bundle = skillBundles.find(b => b.id === bundleId);
|
||
if (bundle) {
|
||
for (const skillId of bundle.skills) {
|
||
await this.loadSkill(skillId, { required: false });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async loadSkill(
|
||
skillId: string,
|
||
opts: { required: boolean }
|
||
): Promise<{ status: 'loaded' | 'failed'; error?: string }> {
|
||
try {
|
||
// 通过 Gateway RPC 启用技能
|
||
await this.gateway.rpc('skills.enable', { skillId });
|
||
return { status: 'loaded' };
|
||
} catch (error) {
|
||
if (opts.required) {
|
||
throw new Error(`Failed to load required skill: ${skillId}`);
|
||
}
|
||
return { status: 'failed', error: String(error) };
|
||
}
|
||
}
|
||
|
||
private async loadExtension(
|
||
extId: string,
|
||
opts: { required: boolean }
|
||
): Promise<{ status: 'loaded' | 'failed'; error?: string }> {
|
||
try {
|
||
// 通过 Gateway RPC 启用扩展
|
||
await this.gateway.rpc('plugins.enable', { pluginId: extId });
|
||
return { status: 'loaded' };
|
||
} catch (error) {
|
||
if (opts.required) {
|
||
throw new Error(`Failed to load required extension: ${extId}`);
|
||
}
|
||
return { status: 'failed', error: String(error) };
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.5.3 预装状态管理
|
||
|
||
```typescript
|
||
// src/stores/presets.ts
|
||
|
||
import { create } from 'zustand';
|
||
import { persist } from 'zustand/middleware';
|
||
|
||
interface PresetState {
|
||
/** 是否已完成首次预装 */
|
||
initialized: boolean;
|
||
|
||
/** 用户选择的技能包 */
|
||
selectedBundles: string[];
|
||
|
||
/** 用户额外启用的技能 */
|
||
enabledSkills: string[];
|
||
|
||
/** 用户禁用的默认技能 (不包括核心技能) */
|
||
disabledSkills: string[];
|
||
|
||
/** 用户额外启用的扩展 */
|
||
enabledExtensions: string[];
|
||
|
||
/** 用户禁用的默认扩展 (不包括核心扩展) */
|
||
disabledExtensions: string[];
|
||
|
||
// Actions
|
||
setInitialized: (value: boolean) => void;
|
||
setSelectedBundles: (bundles: string[]) => void;
|
||
toggleSkill: (skillId: string, enabled: boolean) => void;
|
||
toggleExtension: (extId: string, enabled: boolean) => void;
|
||
|
||
// Computed
|
||
getEffectiveSkills: () => string[];
|
||
getEffectiveExtensions: () => string[];
|
||
}
|
||
|
||
export const usePresetStore = create<PresetState>()(
|
||
persist(
|
||
(set, get) => ({
|
||
initialized: false,
|
||
selectedBundles: [],
|
||
enabledSkills: [],
|
||
disabledSkills: [],
|
||
enabledExtensions: [],
|
||
disabledExtensions: [],
|
||
|
||
setInitialized: (value) => set({ initialized: value }),
|
||
|
||
setSelectedBundles: (bundles) => set({ selectedBundles: bundles }),
|
||
|
||
toggleSkill: (skillId, enabled) => {
|
||
const { enabledSkills, disabledSkills } = get();
|
||
if (enabled) {
|
||
set({
|
||
enabledSkills: [...enabledSkills, skillId],
|
||
disabledSkills: disabledSkills.filter(id => id !== skillId),
|
||
});
|
||
} else {
|
||
set({
|
||
enabledSkills: enabledSkills.filter(id => id !== skillId),
|
||
disabledSkills: [...disabledSkills, skillId],
|
||
});
|
||
}
|
||
},
|
||
|
||
toggleExtension: (extId, enabled) => {
|
||
const { enabledExtensions, disabledExtensions } = get();
|
||
if (enabled) {
|
||
set({
|
||
enabledExtensions: [...enabledExtensions, extId],
|
||
disabledExtensions: disabledExtensions.filter(id => id !== extId),
|
||
});
|
||
} else {
|
||
set({
|
||
enabledExtensions: enabledExtensions.filter(id => id !== extId),
|
||
disabledExtensions: [...disabledExtensions, extId],
|
||
});
|
||
}
|
||
},
|
||
|
||
// 计算实际生效的技能列表
|
||
getEffectiveSkills: () => {
|
||
const { selectedBundles, enabledSkills, disabledSkills } = get();
|
||
const { CLAWX_PRESETS } = require('../../electron/presets/defaults');
|
||
const { skillBundles } = require('../../resources/skills/bundles');
|
||
|
||
// 1. 核心技能 (始终包含)
|
||
const skills = new Set(CLAWX_PRESETS.coreSkills);
|
||
|
||
// 2. 默认技能
|
||
CLAWX_PRESETS.skills.forEach((id: string) => skills.add(id));
|
||
|
||
// 3. 选中的技能包
|
||
for (const bundleId of selectedBundles) {
|
||
const bundle = skillBundles.find((b: any) => b.id === bundleId);
|
||
bundle?.skills.forEach((id: string) => skills.add(id));
|
||
}
|
||
|
||
// 4. 用户额外启用的
|
||
enabledSkills.forEach(id => skills.add(id));
|
||
|
||
// 5. 移除用户禁用的 (但不能移除核心技能)
|
||
disabledSkills.forEach(id => {
|
||
if (!CLAWX_PRESETS.coreSkills.includes(id)) {
|
||
skills.delete(id);
|
||
}
|
||
});
|
||
|
||
return Array.from(skills);
|
||
},
|
||
|
||
getEffectiveExtensions: () => {
|
||
// 类似逻辑...
|
||
const { enabledExtensions, disabledExtensions } = get();
|
||
const { CLAWX_PRESETS } = require('../../electron/presets/defaults');
|
||
|
||
const extensions = new Set(CLAWX_PRESETS.coreExtensions);
|
||
CLAWX_PRESETS.extensions.forEach((id: string) => extensions.add(id));
|
||
enabledExtensions.forEach(id => extensions.add(id));
|
||
disabledExtensions.forEach(id => {
|
||
if (!CLAWX_PRESETS.coreExtensions.includes(id)) {
|
||
extensions.delete(id);
|
||
}
|
||
});
|
||
|
||
return Array.from(extensions);
|
||
},
|
||
}),
|
||
{
|
||
name: 'clawx-presets',
|
||
}
|
||
)
|
||
);
|
||
```
|
||
|
||
#### 2.5.4 安装向导集成
|
||
|
||
```typescript
|
||
// src/pages/Setup/SkillStep.tsx
|
||
|
||
import { usePresetStore } from '@/stores/presets';
|
||
import { skillBundles } from '@/resources/skills/bundles';
|
||
import { CLAWX_PRESETS } from '@electron/presets/defaults';
|
||
|
||
export function SkillStep({ onNext, onBack }: StepProps) {
|
||
const { selectedBundles, setSelectedBundles } = usePresetStore();
|
||
|
||
// 默认预选推荐的技能包
|
||
const [selected, setSelected] = useState<string[]>(
|
||
selectedBundles.length > 0
|
||
? selectedBundles
|
||
: CLAWX_PRESETS.defaultBundles
|
||
);
|
||
|
||
const handleToggle = (bundleId: string) => {
|
||
setSelected(prev =>
|
||
prev.includes(bundleId)
|
||
? prev.filter(id => id !== bundleId)
|
||
: [...prev, bundleId]
|
||
);
|
||
};
|
||
|
||
const handleNext = () => {
|
||
setSelectedBundles(selected);
|
||
onNext({ bundles: selected });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{skillBundles.map(bundle => (
|
||
<Card
|
||
key={bundle.id}
|
||
className={cn(
|
||
'cursor-pointer transition-all',
|
||
selected.includes(bundle.id) && 'ring-2 ring-primary'
|
||
)}
|
||
onClick={() => handleToggle(bundle.id)}
|
||
>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-3xl">{bundle.icon}</span>
|
||
<div>
|
||
<CardTitle className="text-lg">{bundle.nameZh}</CardTitle>
|
||
<CardDescription>{bundle.descriptionZh}</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-wrap gap-1">
|
||
{bundle.skills.slice(0, 4).map(skill => (
|
||
<Badge key={skill} variant="secondary" className="text-xs">
|
||
{skill}
|
||
</Badge>
|
||
))}
|
||
{bundle.skills.length > 4 && (
|
||
<Badge variant="outline" className="text-xs">
|
||
+{bundle.skills.length - 4}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
{bundle.recommended && (
|
||
<div className="absolute top-2 right-2">
|
||
<Badge>推荐</Badge>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* 核心技能提示 */}
|
||
<Alert>
|
||
<Info className="h-4 w-4" />
|
||
<AlertDescription>
|
||
以下核心技能将始终启用:
|
||
{CLAWX_PRESETS.coreSkills.map(id => (
|
||
<Badge key={id} variant="outline" className="ml-1">{id}</Badge>
|
||
))}
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
<div className="flex justify-between">
|
||
<Button variant="ghost" onClick={onBack}>上一步</Button>
|
||
<Button onClick={handleNext}>下一步</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 2.5.5 预装层级与优先级
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ClawX 预装层级架构 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Layer 0: 核心层 (Core) - 不可禁用 │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ Skills: coding-agent │ │
|
||
│ │ Extensions: memory-core │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ ▲ │
|
||
│ │ 始终启用 │
|
||
│ │
|
||
│ Layer 1: 默认层 (Default) - 可禁用 │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ Skills: canvas, summarize, weather, github, clawhub │ │
|
||
│ │ Extensions: lobster │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ ▲ │
|
||
│ │ 首次安装自动启用 │
|
||
│ │
|
||
│ Layer 2: 技能包层 (Bundle) - 用户选择 │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ productivity: apple-reminders, notion, obsidian... │ │
|
||
│ │ developer: github, coding-agent, tmux... │ │
|
||
│ │ smart-home: openhue, sonoscli, spotify-player... │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ ▲ │
|
||
│ │ 安装向导中选择 │
|
||
│ │
|
||
│ Layer 3: 用户层 (User) - 完全自定义 │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ 用户手动启用/禁用的技能和扩展 │ │
|
||
│ │ (通过设置页面或技能市场) │ │
|
||
│ └─────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
优先级: Layer 3 > Layer 2 > Layer 1 > Layer 0 (不可覆盖)
|
||
```
|
||
|
||
#### 2.5.6 配置文件结构
|
||
|
||
```json5
|
||
// ~/.clawx/presets.json (用户本地配置)
|
||
{
|
||
"version": 1,
|
||
"initialized": true,
|
||
"initializedAt": "2026-02-05T12:00:00Z",
|
||
|
||
// 用户选择的技能包
|
||
"bundles": ["productivity", "developer"],
|
||
|
||
// 用户自定义覆盖
|
||
"overrides": {
|
||
"skills": {
|
||
"enabled": ["custom-skill-1"], // 额外启用
|
||
"disabled": ["weather"] // 禁用默认
|
||
},
|
||
"extensions": {
|
||
"enabled": ["custom-ext-1"],
|
||
"disabled": []
|
||
}
|
||
},
|
||
|
||
// 同步到 OpenClaw 的配置 (生成后写入 ~/.openclaw/config.json)
|
||
"syncedConfig": {
|
||
"skills": {
|
||
"enabled": ["coding-agent", "canvas", "github", "notion", "custom-skill-1"]
|
||
},
|
||
"plugins": {
|
||
"entries": {
|
||
"memory-core": { "enabled": true },
|
||
"lobster": { "enabled": true }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.6 跨平台自动更新系统
|
||
|
||
ClawX 需要实现**主动式**自动更新机制,在新版本发布后主动通知用户,同时支持用户手动检查更新。
|
||
|
||
#### 2.6.1 更新策略设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ClawX 自动更新流程 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ 启动检查 │ │ 定时检查 │ │ 手动检查 │ │
|
||
│ │ (App Start) │ │ (每6小时) │ │ (用户触发) │ │
|
||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||
│ │ │ │ │
|
||
│ └───────────────────┼───────────────────┘ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────┐ │
|
||
│ │ Version Checker │ │
|
||
│ │ 检查 GitHub/CDN │ │
|
||
│ └──────────┬──────────┘ │
|
||
│ │ │
|
||
│ ┌──────────────┴──────────────┐ │
|
||
│ ▼ ▼ │
|
||
│ ┌────────────────┐ ┌────────────────┐ │
|
||
│ │ 无新版本 │ │ 有新版本 │ │
|
||
│ │ 静默结束 │ │ │ │
|
||
│ └────────────────┘ └───────┬────────┘ │
|
||
│ │ │
|
||
│ ┌───────────┴───────────┐ │
|
||
│ ▼ ▼ │
|
||
│ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ 主动通知弹窗 │ │ 静默下载 │ │
|
||
│ │ (询问用户) │ │ (后台进行) │ │
|
||
│ └──────┬───────┘ └──────┬───────┘ │
|
||
│ │ │ │
|
||
│ ┌────────────┴────────────┐ │ │
|
||
│ ▼ ▼ │ │
|
||
│ ┌────────────────┐ ┌────────────────┐ │ │
|
||
│ │ 立即更新 │ │ 稍后提醒 │ │ │
|
||
│ │ (下载安装) │ │ (记录时间) │ │ │
|
||
│ └───────┬────────┘ └────────────────┘ │ │
|
||
│ │ │ │
|
||
│ └────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────┐ │
|
||
│ │ 下载完成通知 │ │
|
||
│ │ "重启以完成更新" │ │
|
||
│ └─────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.6.2 更新管理器实现
|
||
|
||
```typescript
|
||
// electron/updater/index.ts
|
||
|
||
import { autoUpdater, UpdateInfo } from 'electron-updater';
|
||
import { app, BrowserWindow, dialog, Notification } from 'electron';
|
||
import { EventEmitter } from 'events';
|
||
import log from 'electron-log';
|
||
|
||
export interface UpdateConfig {
|
||
/** 更新通道: stable | beta | dev */
|
||
channel: 'stable' | 'beta' | 'dev';
|
||
|
||
/** 是否自动下载 */
|
||
autoDownload: boolean;
|
||
|
||
/** 是否允许降级 */
|
||
allowDowngrade: boolean;
|
||
|
||
/** 检查间隔 (毫秒) */
|
||
checkInterval: number;
|
||
|
||
/** 是否显示主动通知 */
|
||
showNotification: boolean;
|
||
}
|
||
|
||
export interface UpdateStatus {
|
||
state: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||
currentVersion: string;
|
||
latestVersion?: string;
|
||
releaseNotes?: string;
|
||
downloadProgress?: number;
|
||
error?: string;
|
||
}
|
||
|
||
const DEFAULT_CONFIG: UpdateConfig = {
|
||
channel: 'stable',
|
||
autoDownload: false, // 默认不自动下载,先询问用户
|
||
allowDowngrade: false,
|
||
checkInterval: 6 * 60 * 60 * 1000, // 6小时
|
||
showNotification: true,
|
||
};
|
||
|
||
export class UpdateManager extends EventEmitter {
|
||
private config: UpdateConfig;
|
||
private status: UpdateStatus;
|
||
private checkTimer: NodeJS.Timeout | null = null;
|
||
private mainWindow: BrowserWindow | null = null;
|
||
|
||
constructor(config: Partial<UpdateConfig> = {}) {
|
||
super();
|
||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||
this.status = {
|
||
state: 'idle',
|
||
currentVersion: app.getVersion(),
|
||
};
|
||
|
||
this.setupAutoUpdater();
|
||
}
|
||
|
||
private setupAutoUpdater(): void {
|
||
// 配置 electron-updater
|
||
autoUpdater.logger = log;
|
||
autoUpdater.autoDownload = this.config.autoDownload;
|
||
autoUpdater.allowDowngrade = this.config.allowDowngrade;
|
||
|
||
// 设置更新通道
|
||
autoUpdater.channel = this.config.channel;
|
||
|
||
// GitHub Releases 作为更新源
|
||
autoUpdater.setFeedURL({
|
||
provider: 'github',
|
||
owner: 'clawx',
|
||
repo: 'clawx',
|
||
});
|
||
|
||
// 事件监听
|
||
autoUpdater.on('checking-for-update', () => {
|
||
this.setStatus({ state: 'checking' });
|
||
});
|
||
|
||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||
this.setStatus({
|
||
state: 'available',
|
||
latestVersion: info.version,
|
||
releaseNotes: this.formatReleaseNotes(info.releaseNotes),
|
||
});
|
||
|
||
// 主动通知用户
|
||
if (this.config.showNotification) {
|
||
this.showUpdateNotification(info);
|
||
}
|
||
});
|
||
|
||
autoUpdater.on('update-not-available', () => {
|
||
this.setStatus({ state: 'idle' });
|
||
});
|
||
|
||
autoUpdater.on('download-progress', (progress) => {
|
||
this.setStatus({
|
||
state: 'downloading',
|
||
downloadProgress: Math.round(progress.percent),
|
||
});
|
||
});
|
||
|
||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
|
||
this.setStatus({ state: 'downloaded' });
|
||
this.showDownloadedNotification(info);
|
||
});
|
||
|
||
autoUpdater.on('error', (error) => {
|
||
this.setStatus({ state: 'error', error: error.message });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 初始化更新器 (应用启动时调用)
|
||
*/
|
||
initialize(mainWindow: BrowserWindow): void {
|
||
this.mainWindow = mainWindow;
|
||
|
||
// 启动时检查更新 (延迟5秒,避免影响启动速度)
|
||
setTimeout(() => this.checkForUpdates(true), 5000);
|
||
|
||
// 定时检查
|
||
this.startPeriodicCheck();
|
||
}
|
||
|
||
/**
|
||
* 启动定时检查
|
||
*/
|
||
private startPeriodicCheck(): void {
|
||
if (this.checkTimer) {
|
||
clearInterval(this.checkTimer);
|
||
}
|
||
|
||
this.checkTimer = setInterval(() => {
|
||
this.checkForUpdates(true);
|
||
}, this.config.checkInterval);
|
||
}
|
||
|
||
/**
|
||
* 检查更新
|
||
* @param silent 是否静默 (不显示"已是最新版本"提示)
|
||
*/
|
||
async checkForUpdates(silent: boolean = false): Promise<void> {
|
||
try {
|
||
const result = await autoUpdater.checkForUpdates();
|
||
|
||
if (!result?.updateInfo && !silent) {
|
||
// 手动检查且无更新时,显示提示
|
||
this.showNoUpdateDialog();
|
||
}
|
||
} catch (error) {
|
||
if (!silent) {
|
||
this.showErrorDialog(error as Error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载更新
|
||
*/
|
||
async downloadUpdate(): Promise<void> {
|
||
if (this.status.state !== 'available') return;
|
||
await autoUpdater.downloadUpdate();
|
||
}
|
||
|
||
/**
|
||
* 安装更新并重启
|
||
*/
|
||
quitAndInstall(): void {
|
||
autoUpdater.quitAndInstall(false, true);
|
||
}
|
||
|
||
/**
|
||
* 显示更新可用通知
|
||
*/
|
||
private showUpdateNotification(info: UpdateInfo): void {
|
||
// 系统通知
|
||
if (Notification.isSupported()) {
|
||
const notification = new Notification({
|
||
title: 'ClawX 有新版本可用',
|
||
body: `版本 ${info.version} 已发布,点击查看详情`,
|
||
icon: app.isPackaged
|
||
? undefined
|
||
: 'resources/icons/icon.png',
|
||
});
|
||
|
||
notification.on('click', () => {
|
||
this.showUpdateDialog(info);
|
||
});
|
||
|
||
notification.show();
|
||
}
|
||
|
||
// 同时发送到渲染进程
|
||
this.mainWindow?.webContents.send('update:available', {
|
||
version: info.version,
|
||
releaseNotes: this.formatReleaseNotes(info.releaseNotes),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示更新对话框 (主动询问用户)
|
||
*/
|
||
private async showUpdateDialog(info: UpdateInfo): Promise<void> {
|
||
const releaseNotes = this.formatReleaseNotes(info.releaseNotes);
|
||
|
||
const result = await dialog.showMessageBox(this.mainWindow!, {
|
||
type: 'info',
|
||
title: '发现新版本',
|
||
message: `ClawX ${info.version} 已发布`,
|
||
detail: `当前版本: ${this.status.currentVersion}\n\n更新内容:\n${releaseNotes}`,
|
||
buttons: ['立即更新', '稍后提醒', '跳过此版本'],
|
||
defaultId: 0,
|
||
cancelId: 1,
|
||
});
|
||
|
||
switch (result.response) {
|
||
case 0: // 立即更新
|
||
this.downloadUpdate();
|
||
break;
|
||
case 1: // 稍后提醒
|
||
// 30分钟后再次提醒
|
||
setTimeout(() => this.showUpdateNotification(info), 30 * 60 * 1000);
|
||
break;
|
||
case 2: // 跳过此版本
|
||
this.skipVersion(info.version);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示下载完成通知
|
||
*/
|
||
private showDownloadedNotification(info: UpdateInfo): void {
|
||
if (Notification.isSupported()) {
|
||
const notification = new Notification({
|
||
title: '更新已就绪',
|
||
body: `ClawX ${info.version} 已下载完成,点击重启应用以完成更新`,
|
||
});
|
||
|
||
notification.on('click', () => {
|
||
this.showRestartDialog();
|
||
});
|
||
|
||
notification.show();
|
||
}
|
||
|
||
this.mainWindow?.webContents.send('update:downloaded', {
|
||
version: info.version,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示重启对话框
|
||
*/
|
||
private async showRestartDialog(): Promise<void> {
|
||
const result = await dialog.showMessageBox(this.mainWindow!, {
|
||
type: 'info',
|
||
title: '更新已就绪',
|
||
message: '重启应用以完成更新',
|
||
detail: '更新已下载完成,是否立即重启应用?',
|
||
buttons: ['立即重启', '稍后'],
|
||
defaultId: 0,
|
||
});
|
||
|
||
if (result.response === 0) {
|
||
this.quitAndInstall();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示无更新对话框 (手动检查时)
|
||
*/
|
||
private showNoUpdateDialog(): void {
|
||
dialog.showMessageBox(this.mainWindow!, {
|
||
type: 'info',
|
||
title: '检查更新',
|
||
message: '已是最新版本',
|
||
detail: `当前版本 ${this.status.currentVersion} 已是最新`,
|
||
buttons: ['确定'],
|
||
});
|
||
}
|
||
|
||
private showErrorDialog(error: Error): void {
|
||
dialog.showMessageBox(this.mainWindow!, {
|
||
type: 'error',
|
||
title: '更新检查失败',
|
||
message: '无法检查更新',
|
||
detail: error.message,
|
||
buttons: ['确定'],
|
||
});
|
||
}
|
||
|
||
private formatReleaseNotes(notes: string | any): string {
|
||
if (typeof notes === 'string') return notes;
|
||
if (Array.isArray(notes)) {
|
||
return notes.map(n => n.note || n).join('\n');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
private skipVersion(version: string): void {
|
||
// 存储跳过的版本
|
||
const store = require('electron-store');
|
||
const settings = new store();
|
||
const skipped = settings.get('update.skippedVersions', []) as string[];
|
||
if (!skipped.includes(version)) {
|
||
settings.set('update.skippedVersions', [...skipped, version]);
|
||
}
|
||
}
|
||
|
||
private setStatus(update: Partial<UpdateStatus>): void {
|
||
this.status = { ...this.status, ...update };
|
||
this.emit('status', this.status);
|
||
this.mainWindow?.webContents.send('update:status', this.status);
|
||
}
|
||
|
||
getStatus(): UpdateStatus {
|
||
return this.status;
|
||
}
|
||
|
||
setConfig(config: Partial<UpdateConfig>): void {
|
||
this.config = { ...this.config, ...config };
|
||
autoUpdater.channel = this.config.channel;
|
||
autoUpdater.autoDownload = this.config.autoDownload;
|
||
|
||
// 重启定时检查
|
||
this.startPeriodicCheck();
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const updateManager = new UpdateManager();
|
||
```
|
||
|
||
#### 2.6.3 渲染进程更新 UI
|
||
|
||
```typescript
|
||
// src/hooks/useUpdate.ts
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface UpdateStatus {
|
||
state: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||
currentVersion: string;
|
||
latestVersion?: string;
|
||
releaseNotes?: string;
|
||
downloadProgress?: number;
|
||
error?: string;
|
||
}
|
||
|
||
export function useUpdate() {
|
||
const [status, setStatus] = useState<UpdateStatus>({
|
||
state: 'idle',
|
||
currentVersion: '0.0.0',
|
||
});
|
||
|
||
useEffect(() => {
|
||
// 监听主进程更新状态
|
||
const handleStatus = (_: any, newStatus: UpdateStatus) => {
|
||
setStatus(newStatus);
|
||
};
|
||
|
||
window.electron.ipcRenderer.on('update:status', handleStatus);
|
||
|
||
// 获取初始状态
|
||
window.electron.ipcRenderer.invoke('update:getStatus').then(setStatus);
|
||
|
||
return () => {
|
||
window.electron.ipcRenderer.off('update:status', handleStatus);
|
||
};
|
||
}, []);
|
||
|
||
const checkForUpdates = () => {
|
||
window.electron.ipcRenderer.invoke('update:check');
|
||
};
|
||
|
||
const downloadUpdate = () => {
|
||
window.electron.ipcRenderer.invoke('update:download');
|
||
};
|
||
|
||
const installUpdate = () => {
|
||
window.electron.ipcRenderer.invoke('update:install');
|
||
};
|
||
|
||
return {
|
||
status,
|
||
checkForUpdates,
|
||
downloadUpdate,
|
||
installUpdate,
|
||
};
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// src/components/UpdateNotification.tsx
|
||
|
||
import { useUpdate } from '@/hooks/useUpdate';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { Download, RefreshCw, X, Loader2 } from 'lucide-react';
|
||
|
||
export function UpdateNotification() {
|
||
const { status, downloadUpdate, installUpdate } = useUpdate();
|
||
const [dismissed, setDismissed] = useState(false);
|
||
|
||
// 只在有更新可用或已下载时显示
|
||
const shouldShow =
|
||
!dismissed &&
|
||
(status.state === 'available' || status.state === 'downloaded');
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{shouldShow && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
className="fixed top-4 right-4 z-50 max-w-sm"
|
||
>
|
||
<Card className="border-primary/50 shadow-lg">
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||
{status.state === 'available' ? (
|
||
<>
|
||
<Download className="w-4 h-4 text-primary" />
|
||
新版本可用
|
||
</>
|
||
) : (
|
||
<>
|
||
<RefreshCw className="w-4 h-4 text-green-500" />
|
||
更新已就绪
|
||
</>
|
||
)}
|
||
</CardTitle>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6"
|
||
onClick={() => setDismissed(true)}
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pt-0">
|
||
<p className="text-sm text-muted-foreground mb-3">
|
||
ClawX {status.latestVersion} 已发布
|
||
</p>
|
||
|
||
{status.state === 'downloading' && (
|
||
<div className="mb-3">
|
||
<Progress value={status.downloadProgress} className="h-2" />
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
下载中 {status.downloadProgress}%
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
{status.state === 'available' && (
|
||
<Button size="sm" onClick={downloadUpdate}>
|
||
立即下载
|
||
</Button>
|
||
)}
|
||
{status.state === 'downloaded' && (
|
||
<Button size="sm" onClick={installUpdate}>
|
||
重启更新
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setDismissed(true)}
|
||
>
|
||
稍后
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 2.6.4 设置页面手动检查入口
|
||
|
||
```typescript
|
||
// src/pages/Settings/GeneralSettings.tsx
|
||
|
||
import { useUpdate } from '@/hooks/useUpdate';
|
||
|
||
export function GeneralSettings() {
|
||
const { status, checkForUpdates } = useUpdate();
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>通用设置</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* 版本与更新 */}
|
||
<div className="space-y-4">
|
||
<Label className="text-sm font-medium">版本与更新</Label>
|
||
|
||
<div className="flex items-center justify-between p-4 rounded-lg border">
|
||
<div>
|
||
<p className="font-medium">ClawX</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
当前版本: {status.currentVersion}
|
||
</p>
|
||
{status.latestVersion && status.state === 'available' && (
|
||
<p className="text-sm text-primary">
|
||
新版本可用: {status.latestVersion}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
onClick={checkForUpdates}
|
||
disabled={status.state === 'checking'}
|
||
>
|
||
{status.state === 'checking' ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
检查中...
|
||
</>
|
||
) : (
|
||
'检查更新'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 更新设置 */}
|
||
<div className="space-y-3 pt-2">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">自动检查更新</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
启动时和每6小时自动检查
|
||
</p>
|
||
</div>
|
||
<Switch defaultChecked />
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">自动下载更新</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
发现新版本后在后台自动下载
|
||
</p>
|
||
</div>
|
||
<Switch />
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">更新通道</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
选择接收更新的类型
|
||
</p>
|
||
</div>
|
||
<Select defaultValue="stable">
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="stable">稳定版</SelectItem>
|
||
<SelectItem value="beta">测试版</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 其他设置... */}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 2.6.5 跨平台配置
|
||
|
||
```typescript
|
||
// electron/updater/channels.ts
|
||
|
||
import { Platform } from 'electron-builder';
|
||
|
||
export interface PlatformUpdateConfig {
|
||
/** 更新源 URL */
|
||
feedUrl: string;
|
||
|
||
/** 是否支持差量更新 */
|
||
supportsDelta: boolean;
|
||
|
||
/** 安装方式 */
|
||
installMethod: 'nsis' | 'dmg' | 'appimage' | 'deb' | 'rpm';
|
||
}
|
||
|
||
export const PLATFORM_CONFIGS: Record<NodeJS.Platform, PlatformUpdateConfig> = {
|
||
darwin: {
|
||
feedUrl: 'https://releases.clawx.app/mac',
|
||
supportsDelta: true, // macOS 支持 Sparkle 差量更新
|
||
installMethod: 'dmg',
|
||
},
|
||
win32: {
|
||
feedUrl: 'https://releases.clawx.app/win',
|
||
supportsDelta: true, // Windows NSIS 支持差量
|
||
installMethod: 'nsis',
|
||
},
|
||
linux: {
|
||
feedUrl: 'https://releases.clawx.app/linux',
|
||
supportsDelta: false, // AppImage 不支持差量
|
||
installMethod: 'appimage',
|
||
},
|
||
} as Record<NodeJS.Platform, PlatformUpdateConfig>;
|
||
|
||
// electron-builder.yml 配置示例
|
||
export const ELECTRON_BUILDER_CONFIG = `
|
||
appId: app.clawx.desktop
|
||
productName: ClawX
|
||
copyright: Copyright © 2026 ClawX
|
||
|
||
publish:
|
||
- provider: github
|
||
owner: clawx
|
||
repo: clawx
|
||
releaseType: release
|
||
|
||
mac:
|
||
category: public.app-category.productivity
|
||
target:
|
||
- target: dmg
|
||
arch: [universal]
|
||
- target: zip
|
||
arch: [universal]
|
||
notarize:
|
||
teamId: \${env.APPLE_TEAM_ID}
|
||
|
||
win:
|
||
target:
|
||
- target: nsis
|
||
arch: [x64, arm64]
|
||
publisherName: ClawX Inc.
|
||
|
||
nsis:
|
||
oneClick: false
|
||
allowToChangeInstallationDirectory: true
|
||
differentialPackage: true # 启用差量更新
|
||
|
||
linux:
|
||
target:
|
||
- target: AppImage
|
||
arch: [x64, arm64]
|
||
- target: deb
|
||
arch: [x64]
|
||
category: Utility
|
||
`;
|
||
```
|
||
|
||
#### 2.6.6 更新时间线与用户交互
|
||
|
||
```
|
||
用户视角的更新流程:
|
||
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 场景 A: 有新版本时主动通知 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. 用户正在使用 ClawX │
|
||
│ │ │
|
||
│ 2. 系统检测到新版本 (启动时/定时检查) │
|
||
│ │ │
|
||
│ 3. 右上角弹出通知卡片 ────────────────────┐ │
|
||
│ │ │ │
|
||
│ │ ┌─────────────────────────────┐ │ │
|
||
│ │ │ 🔔 新版本可用 │ │ │
|
||
│ │ │ ClawX 1.2.0 已发布 │ │ │
|
||
│ │ │ [立即下载] [稍后] │ │ │
|
||
│ │ └─────────────────────────────┘ │ │
|
||
│ │ │ │
|
||
│ 4. 用户点击"立即下载" │
|
||
│ │ │
|
||
│ 5. 后台下载,通知卡片显示进度 │
|
||
│ │ │
|
||
│ 6. 下载完成,通知"重启以完成更新" │
|
||
│ │ │
|
||
│ 7. 用户选择"立即重启"或继续工作后重启 │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ 场景 B: 用户手动检查更新 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 1. 用户打开 设置 → 通用 → 检查更新 │
|
||
│ │ │
|
||
│ 2. 显示"检查中..." │
|
||
│ │ │
|
||
│ 3a. 无更新 → 弹窗提示"已是最新版本 (1.1.0)" │
|
||
│ │ │
|
||
│ 3b. 有更新 → 弹窗显示更新详情 │
|
||
│ │ │
|
||
│ │ ┌─────────────────────────────────────┐ │
|
||
│ │ │ 发现新版本 │ │
|
||
│ │ │ │ │
|
||
│ │ │ ClawX 1.2.0 已发布 │ │
|
||
│ │ │ 当前版本: 1.1.0 │ │
|
||
│ │ │ │ │
|
||
│ │ │ 更新内容: │ │
|
||
│ │ │ • 新增技能市场 │ │
|
||
│ │ │ • 修复 Windows 启动问题 │ │
|
||
│ │ │ • 性能优化 │ │
|
||
│ │ │ │ │
|
||
│ │ │ [立即更新] [稍后提醒] [跳过此版本] │ │
|
||
│ │ └─────────────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2.7 GUI 权限提升机制
|
||
|
||
ClawX 需要在某些操作时获取管理员权限(如全局安装 Node.js、修改系统 PATH),必须通过**图形化密码弹窗**而非命令行 `sudo`。
|
||
|
||
#### 2.7.1 需要管理员权限的场景
|
||
|
||
| 场景 | 操作 | 原因 |
|
||
|------|------|------|
|
||
| Node.js 安装 | 写入 `/usr/local/bin` (macOS/Linux) | 系统目录需要 root |
|
||
| 全局 npm 安装 | `npm install -g openclaw` | 系统 node_modules |
|
||
| PATH 配置 | 修改 `/etc/paths.d/` | 系统级环境变量 |
|
||
| 端口 < 1024 | Gateway 使用低端口 | 特权端口 |
|
||
| 系统服务注册 | LaunchDaemon / systemd | 开机自启 |
|
||
|
||
#### 2.7.2 跨平台权限提升架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ ClawX 权限提升流程 │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────────────┐ │
|
||
│ │ 需要管理员权限 │ │
|
||
│ │ 的操作触发 │ │
|
||
│ └────────┬─────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────────┐ │
|
||
│ │ PrivilegeManager │ │
|
||
│ │ 检测当前平台 │ │
|
||
│ └────────┬─────────┘ │
|
||
│ │ │
|
||
│ ┌─────┴─────┬─────────────┐ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||
│ │macOS │ │Windows│ │Linux │ │
|
||
│ └──┬───┘ └──┬───┘ └──┬───┘ │
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||
│ │osascript│ │ UAC │ │pkexec │ │
|
||
│ │密码弹窗 │ │提升弹窗│ │密码弹窗│ │
|
||
│ └────────┘ └────────┘ └────────┘ │
|
||
│ │ │ │ │
|
||
│ └──────────┴───────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────────────────┐ │
|
||
│ │ 以管理员身份执行命令 │ │
|
||
│ │ (无需终端 sudo 输入) │ │
|
||
│ └──────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 2.7.3 权限管理器实现
|
||
|
||
```typescript
|
||
// electron/privilege/index.ts
|
||
|
||
import { dialog } from 'electron';
|
||
import { platform } from 'os';
|
||
import { DarwinAdmin } from './darwin-admin';
|
||
import { Win32Admin } from './win32-admin';
|
||
import { LinuxAdmin } from './linux-admin';
|
||
|
||
export interface PrivilegeResult {
|
||
success: boolean;
|
||
stdout?: string;
|
||
stderr?: string;
|
||
error?: string;
|
||
}
|
||
|
||
export interface PrivilegeOptions {
|
||
/** 向用户展示的操作说明 */
|
||
reason: string;
|
||
|
||
/** 图标路径 */
|
||
icon?: string;
|
||
|
||
/** 超时时间 (毫秒) */
|
||
timeout?: number;
|
||
}
|
||
|
||
/**
|
||
* 跨平台权限提升管理器
|
||
* 通过 GUI 弹窗获取管理员权限,而非命令行 sudo
|
||
*/
|
||
export class PrivilegeManager {
|
||
private admin: DarwinAdmin | Win32Admin | LinuxAdmin;
|
||
|
||
constructor() {
|
||
switch (platform()) {
|
||
case 'darwin':
|
||
this.admin = new DarwinAdmin();
|
||
break;
|
||
case 'win32':
|
||
this.admin = new Win32Admin();
|
||
break;
|
||
case 'linux':
|
||
this.admin = new LinuxAdmin();
|
||
break;
|
||
default:
|
||
throw new Error(`Unsupported platform: ${platform()}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查是否已有管理员权限
|
||
*/
|
||
async hasAdminPrivilege(): Promise<boolean> {
|
||
return this.admin.hasAdminPrivilege();
|
||
}
|
||
|
||
/**
|
||
* 以管理员权限执行命令 (会弹出密码框)
|
||
*/
|
||
async execAsAdmin(
|
||
command: string,
|
||
options: PrivilegeOptions
|
||
): Promise<PrivilegeResult> {
|
||
// 先检查是否真的需要提权
|
||
if (await this.hasAdminPrivilege()) {
|
||
// 已经是 admin,直接执行
|
||
return this.admin.exec(command);
|
||
}
|
||
|
||
// 显示确认对话框
|
||
const confirmed = await this.showConfirmDialog(options.reason);
|
||
if (!confirmed) {
|
||
return { success: false, error: 'User cancelled' };
|
||
}
|
||
|
||
// 通过平台特定方式获取权限并执行
|
||
return this.admin.execWithPrivilege(command, options);
|
||
}
|
||
|
||
/**
|
||
* 显示权限请求确认对话框
|
||
*/
|
||
private async showConfirmDialog(reason: string): Promise<boolean> {
|
||
const result = await dialog.showMessageBox({
|
||
type: 'warning',
|
||
title: '需要管理员权限',
|
||
message: 'ClawX 需要管理员权限来完成此操作',
|
||
detail: `${reason}\n\n点击"继续"后将弹出系统密码输入框。`,
|
||
buttons: ['继续', '取消'],
|
||
defaultId: 0,
|
||
cancelId: 1,
|
||
});
|
||
|
||
return result.response === 0;
|
||
}
|
||
}
|
||
|
||
export const privilegeManager = new PrivilegeManager();
|
||
```
|
||
|
||
#### 2.7.4 macOS 实现 (osascript)
|
||
|
||
```typescript
|
||
// electron/privilege/darwin-admin.ts
|
||
|
||
import { exec } from 'child_process';
|
||
import { promisify } from 'util';
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
export class DarwinAdmin {
|
||
/**
|
||
* 检查是否以 root 运行
|
||
*/
|
||
async hasAdminPrivilege(): Promise<boolean> {
|
||
try {
|
||
await execAsync('test -w /usr/local/bin');
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 直接执行命令
|
||
*/
|
||
async exec(command: string): Promise<PrivilegeResult> {
|
||
try {
|
||
const { stdout, stderr } = await execAsync(command);
|
||
return { success: true, stdout, stderr };
|
||
} catch (error: any) {
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过 osascript 弹出系统密码框执行命令
|
||
* 这会显示 macOS 原生的授权对话框
|
||
*/
|
||
async execWithPrivilege(
|
||
command: string,
|
||
options: PrivilegeOptions
|
||
): Promise<PrivilegeResult> {
|
||
// 使用 osascript 调用 Authorization Services
|
||
// 这会弹出 macOS 原生密码输入框
|
||
const escapedCommand = command.replace(/"/g, '\\"');
|
||
const escapedReason = options.reason.replace(/"/g, '\\"');
|
||
|
||
const appleScript = `
|
||
do shell script "${escapedCommand}" \\
|
||
with administrator privileges \\
|
||
with prompt "${escapedReason}"
|
||
`;
|
||
|
||
try {
|
||
const { stdout, stderr } = await execAsync(
|
||
`osascript -e '${appleScript.replace(/'/g, "'\"'\"'")}'`,
|
||
{ timeout: options.timeout || 60000 }
|
||
);
|
||
return { success: true, stdout, stderr };
|
||
} catch (error: any) {
|
||
// 用户取消会抛出错误
|
||
if (error.message.includes('User canceled')) {
|
||
return { success: false, error: 'User cancelled' };
|
||
}
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.7.5 Windows 实现 (UAC)
|
||
|
||
```typescript
|
||
// electron/privilege/win32-admin.ts
|
||
|
||
import { exec, spawn } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import { writeFileSync, unlinkSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
export class Win32Admin {
|
||
/**
|
||
* 检查是否以管理员运行
|
||
*/
|
||
async hasAdminPrivilege(): Promise<boolean> {
|
||
try {
|
||
// 尝试写入 System32,如果成功说明有管理员权限
|
||
await execAsync('fsutil dirty query %systemdrive%');
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async exec(command: string): Promise<PrivilegeResult> {
|
||
try {
|
||
const { stdout, stderr } = await execAsync(command, { shell: 'powershell.exe' });
|
||
return { success: true, stdout, stderr };
|
||
} catch (error: any) {
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过 UAC 提升权限执行命令
|
||
* 这会弹出 Windows UAC 确认对话框
|
||
*/
|
||
async execWithPrivilege(
|
||
command: string,
|
||
options: PrivilegeOptions
|
||
): Promise<PrivilegeResult> {
|
||
return new Promise((resolve) => {
|
||
// 创建临时 PowerShell 脚本
|
||
const scriptPath = join(tmpdir(), `clawx-admin-${Date.now()}.ps1`);
|
||
const outputPath = join(tmpdir(), `clawx-admin-${Date.now()}.txt`);
|
||
|
||
// PowerShell 脚本内容
|
||
const script = `
|
||
try {
|
||
${command}
|
||
"SUCCESS" | Out-File -FilePath "${outputPath}"
|
||
} catch {
|
||
$_.Exception.Message | Out-File -FilePath "${outputPath}"
|
||
exit 1
|
||
}
|
||
`;
|
||
|
||
writeFileSync(scriptPath, script, 'utf-8');
|
||
|
||
// 使用 PowerShell 的 Start-Process 触发 UAC
|
||
const elevateCommand = `
|
||
Start-Process powershell.exe \
|
||
-ArgumentList '-ExecutionPolicy Bypass -File "${scriptPath}"' \
|
||
-Verb RunAs \
|
||
-Wait
|
||
`;
|
||
|
||
const child = spawn('powershell.exe', ['-Command', elevateCommand], {
|
||
stdio: 'pipe',
|
||
});
|
||
|
||
child.on('close', (code) => {
|
||
try {
|
||
const output = require('fs').readFileSync(outputPath, 'utf-8').trim();
|
||
unlinkSync(scriptPath);
|
||
unlinkSync(outputPath);
|
||
|
||
if (output === 'SUCCESS') {
|
||
resolve({ success: true });
|
||
} else {
|
||
resolve({ success: false, error: output });
|
||
}
|
||
} catch (error: any) {
|
||
resolve({ success: false, error: 'UAC cancelled or failed' });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.7.6 Linux 实现 (pkexec/polkit)
|
||
|
||
```typescript
|
||
// electron/privilege/linux-admin.ts
|
||
|
||
import { exec, spawn } from 'child_process';
|
||
import { promisify } from 'util';
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
export class LinuxAdmin {
|
||
async hasAdminPrivilege(): Promise<boolean> {
|
||
return process.getuid?.() === 0;
|
||
}
|
||
|
||
async exec(command: string): Promise<PrivilegeResult> {
|
||
try {
|
||
const { stdout, stderr } = await execAsync(command);
|
||
return { success: true, stdout, stderr };
|
||
} catch (error: any) {
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过 pkexec 弹出图形化密码框
|
||
* pkexec 是 polkit 的一部分,大多数 Linux 桌面都支持
|
||
*/
|
||
async execWithPrivilege(
|
||
command: string,
|
||
options: PrivilegeOptions
|
||
): Promise<PrivilegeResult> {
|
||
return new Promise((resolve) => {
|
||
// 检查 pkexec 是否可用
|
||
exec('which pkexec', (error) => {
|
||
if (error) {
|
||
// 回退到 gksudo 或 kdesudo
|
||
this.execWithFallback(command, options).then(resolve);
|
||
return;
|
||
}
|
||
|
||
// 使用 pkexec (会弹出 polkit 密码框)
|
||
const child = spawn('pkexec', ['bash', '-c', command], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
env: {
|
||
...process.env,
|
||
// 设置 polkit 显示的描述
|
||
POLKIT_AGENT_HELPER_NAME: 'ClawX',
|
||
},
|
||
});
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
|
||
child.stdout.on('data', (data) => { stdout += data; });
|
||
child.stderr.on('data', (data) => { stderr += data; });
|
||
|
||
child.on('close', (code) => {
|
||
if (code === 0) {
|
||
resolve({ success: true, stdout, stderr });
|
||
} else if (code === 126) {
|
||
// 用户取消
|
||
resolve({ success: false, error: 'User cancelled' });
|
||
} else {
|
||
resolve({ success: false, error: stderr || `Exit code: ${code}` });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* pkexec 不可用时的回退方案
|
||
*/
|
||
private async execWithFallback(
|
||
command: string,
|
||
options: PrivilegeOptions
|
||
): Promise<PrivilegeResult> {
|
||
// 尝试 gksudo (GNOME) 或 kdesudo (KDE)
|
||
const fallbacks = ['gksudo', 'kdesudo', 'sudo -A'];
|
||
|
||
for (const fallback of fallbacks) {
|
||
try {
|
||
const { stdout, stderr } = await execAsync(`${fallback} ${command}`);
|
||
return { success: true, stdout, stderr };
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
error: 'No GUI sudo helper available. Please run ClawX as root.'
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.7.7 使用示例
|
||
|
||
```typescript
|
||
// electron/installer/node-installer.ts
|
||
|
||
import { privilegeManager } from '../privilege';
|
||
|
||
export async function installNodeGlobally(): Promise<void> {
|
||
const isNodeInstalled = await checkNodeInstalled();
|
||
|
||
if (!isNodeInstalled) {
|
||
// 通过 GUI 弹窗获取管理员权限安装 Node.js
|
||
const result = await privilegeManager.execAsAdmin(
|
||
// macOS: 使用 Homebrew
|
||
process.platform === 'darwin'
|
||
? '/opt/homebrew/bin/brew install node@22'
|
||
// Windows: 使用 winget
|
||
: process.platform === 'win32'
|
||
? 'winget install OpenJS.NodeJS.LTS'
|
||
// Linux: 使用包管理器
|
||
: 'apt-get install -y nodejs',
|
||
{
|
||
reason: '安装 Node.js 运行时环境,这是 ClawX 运行的必要组件。',
|
||
}
|
||
);
|
||
|
||
if (!result.success) {
|
||
throw new Error(`Node.js 安装失败: ${result.error}`);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.8 GUI 环境变量配置
|
||
|
||
ClawX 需要管理多种环境变量(API Keys、PATH、代理设置等),必须通过**图形界面**配置,而非要求用户编辑 `.env` 文件或终端。
|
||
|
||
#### 2.8.1 环境变量分类
|
||
|
||
| 类型 | 示例 | 存储位置 | 安全级别 |
|
||
|------|------|----------|----------|
|
||
| **API Keys** | `ANTHROPIC_API_KEY` | 系统密钥链 | 🔐 加密存储 |
|
||
| **PATH** | Node.js/npm 路径 | Shell Profile | 普通 |
|
||
| **代理** | `HTTP_PROXY` | 应用配置 | 普通 |
|
||
| **应用配置** | `CLAWX_PORT` | 应用配置 | 普通 |
|
||
|
||
#### 2.8.2 环境配置管理器
|
||
|
||
```typescript
|
||
// electron/env-config/index.ts
|
||
|
||
import { safeStorage, app } from 'electron';
|
||
import Store from 'electron-store';
|
||
import { PathManager } from './path-manager';
|
||
import { ApiKeyManager } from './api-keys';
|
||
import { ShellProfileManager } from './shell-profile';
|
||
|
||
export interface EnvConfig {
|
||
// API Keys (加密存储)
|
||
apiKeys: {
|
||
anthropic?: string;
|
||
openai?: string;
|
||
google?: string;
|
||
[key: string]: string | undefined;
|
||
};
|
||
|
||
// 代理设置
|
||
proxy: {
|
||
http?: string;
|
||
https?: string;
|
||
noProxy?: string[];
|
||
};
|
||
|
||
// 应用配置
|
||
app: {
|
||
port?: number;
|
||
logLevel?: string;
|
||
dataDir?: string;
|
||
};
|
||
}
|
||
|
||
export class EnvConfigManager {
|
||
private store: Store;
|
||
private pathManager: PathManager;
|
||
private apiKeyManager: ApiKeyManager;
|
||
private shellProfileManager: ShellProfileManager;
|
||
|
||
constructor() {
|
||
this.store = new Store({ name: 'env-config' });
|
||
this.pathManager = new PathManager();
|
||
this.apiKeyManager = new ApiKeyManager();
|
||
this.shellProfileManager = new ShellProfileManager();
|
||
}
|
||
|
||
/**
|
||
* 获取所有环境配置 (API Keys 脱敏)
|
||
*/
|
||
getConfig(): EnvConfig {
|
||
return {
|
||
apiKeys: this.apiKeyManager.getMaskedKeys(),
|
||
proxy: this.store.get('proxy', {}) as EnvConfig['proxy'],
|
||
app: this.store.get('app', {}) as EnvConfig['app'],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 设置 API Key (自动加密存储)
|
||
*/
|
||
async setApiKey(provider: string, key: string): Promise<void> {
|
||
await this.apiKeyManager.setKey(provider, key);
|
||
|
||
// 同步到 OpenClaw 配置
|
||
await this.syncToOpenClaw();
|
||
}
|
||
|
||
/**
|
||
* 设置代理
|
||
*/
|
||
setProxy(proxy: EnvConfig['proxy']): void {
|
||
this.store.set('proxy', proxy);
|
||
this.applyProxy(proxy);
|
||
}
|
||
|
||
/**
|
||
* 确保 PATH 包含必要的路径
|
||
*/
|
||
async ensurePath(): Promise<void> {
|
||
const requiredPaths = [
|
||
'/usr/local/bin',
|
||
'/opt/homebrew/bin', // macOS ARM
|
||
`${app.getPath('home')}/.nvm/versions/node/*/bin`, // nvm
|
||
`${app.getPath('home')}/.local/bin`, // pip install --user
|
||
];
|
||
|
||
for (const p of requiredPaths) {
|
||
await this.pathManager.addToPath(p);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同步配置到 OpenClaw
|
||
*/
|
||
private async syncToOpenClaw(): Promise<void> {
|
||
const openclawConfigPath = `${app.getPath('home')}/.openclaw/config.json`;
|
||
// 读取现有配置,合并 API keys,写回
|
||
// ...
|
||
}
|
||
|
||
private applyProxy(proxy: EnvConfig['proxy']): void {
|
||
if (proxy.http) process.env.HTTP_PROXY = proxy.http;
|
||
if (proxy.https) process.env.HTTPS_PROXY = proxy.https;
|
||
if (proxy.noProxy) process.env.NO_PROXY = proxy.noProxy.join(',');
|
||
}
|
||
}
|
||
|
||
export const envConfigManager = new EnvConfigManager();
|
||
```
|
||
|
||
#### 2.8.3 API Key 安全存储
|
||
|
||
```typescript
|
||
// electron/env-config/api-keys.ts
|
||
|
||
import { safeStorage } from 'electron';
|
||
import Store from 'electron-store';
|
||
import keytar from 'keytar';
|
||
|
||
const SERVICE_NAME = 'ClawX';
|
||
|
||
/**
|
||
* API Key 管理器
|
||
* 使用系统密钥链安全存储敏感信息
|
||
*/
|
||
export class ApiKeyManager {
|
||
private store: Store;
|
||
|
||
constructor() {
|
||
this.store = new Store({ name: 'api-keys-meta' });
|
||
}
|
||
|
||
/**
|
||
* 安全存储 API Key
|
||
* - macOS: 使用 Keychain
|
||
* - Windows: 使用 Credential Manager
|
||
* - Linux: 使用 libsecret (GNOME Keyring / KWallet)
|
||
*/
|
||
async setKey(provider: string, key: string): Promise<void> {
|
||
try {
|
||
// 首选: 系统密钥链 (通过 keytar)
|
||
await keytar.setPassword(SERVICE_NAME, provider, key);
|
||
this.store.set(`providers.${provider}`, { stored: true, method: 'keychain' });
|
||
} catch {
|
||
// 回退: Electron safeStorage (本地加密)
|
||
if (safeStorage.isEncryptionAvailable()) {
|
||
const encrypted = safeStorage.encryptString(key);
|
||
this.store.set(`providers.${provider}`, {
|
||
stored: true,
|
||
method: 'safeStorage',
|
||
encrypted: encrypted.toString('base64'),
|
||
});
|
||
} else {
|
||
throw new Error('No secure storage available');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取 API Key
|
||
*/
|
||
async getKey(provider: string): Promise<string | null> {
|
||
const meta = this.store.get(`providers.${provider}`) as any;
|
||
if (!meta?.stored) return null;
|
||
|
||
if (meta.method === 'keychain') {
|
||
return keytar.getPassword(SERVICE_NAME, provider);
|
||
} else if (meta.method === 'safeStorage' && meta.encrypted) {
|
||
const buffer = Buffer.from(meta.encrypted, 'base64');
|
||
return safeStorage.decryptString(buffer);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 删除 API Key
|
||
*/
|
||
async deleteKey(provider: string): Promise<void> {
|
||
const meta = this.store.get(`providers.${provider}`) as any;
|
||
if (!meta?.stored) return;
|
||
|
||
if (meta.method === 'keychain') {
|
||
await keytar.deletePassword(SERVICE_NAME, provider);
|
||
}
|
||
|
||
this.store.delete(`providers.${provider}`);
|
||
}
|
||
|
||
/**
|
||
* 获取所有已配置的 Key (脱敏显示)
|
||
*/
|
||
getMaskedKeys(): Record<string, string | undefined> {
|
||
const result: Record<string, string | undefined> = {};
|
||
const providers = this.store.get('providers', {}) as Record<string, any>;
|
||
|
||
for (const [provider, meta] of Object.entries(providers)) {
|
||
if (meta?.stored) {
|
||
result[provider] = '••••••••••••'; // 脱敏显示
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 检查 Key 是否已配置
|
||
*/
|
||
hasKey(provider: string): boolean {
|
||
const meta = this.store.get(`providers.${provider}`) as any;
|
||
return meta?.stored === true;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.8.4 PATH 环境变量管理
|
||
|
||
```typescript
|
||
// electron/env-config/path-manager.ts
|
||
|
||
import { exec } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import { existsSync, writeFileSync, readFileSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { homedir, platform } from 'os';
|
||
import { privilegeManager } from '../privilege';
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
export class PathManager {
|
||
/**
|
||
* 添加路径到 PATH 环境变量
|
||
* 通过 GUI 完成,用户无需手动编辑 shell 配置文件
|
||
*/
|
||
async addToPath(newPath: string): Promise<void> {
|
||
// 检查路径是否已存在
|
||
const currentPath = process.env.PATH || '';
|
||
if (currentPath.includes(newPath)) return;
|
||
|
||
// 检查路径是否有效
|
||
if (!existsSync(newPath) && !newPath.includes('*')) return;
|
||
|
||
switch (platform()) {
|
||
case 'darwin':
|
||
await this.addToPathMacOS(newPath);
|
||
break;
|
||
case 'win32':
|
||
await this.addToPathWindows(newPath);
|
||
break;
|
||
case 'linux':
|
||
await this.addToPathLinux(newPath);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* macOS: 添加到 /etc/paths.d/ (系统级) 或 shell profile (用户级)
|
||
*/
|
||
private async addToPathMacOS(newPath: string): Promise<void> {
|
||
// 优先使用用户级配置 (~/.zshrc 或 ~/.bash_profile)
|
||
const shellProfile = this.getShellProfile();
|
||
|
||
if (shellProfile) {
|
||
const content = existsSync(shellProfile)
|
||
? readFileSync(shellProfile, 'utf-8')
|
||
: '';
|
||
|
||
if (!content.includes(newPath)) {
|
||
const exportLine = `\nexport PATH="${newPath}:$PATH"\n`;
|
||
writeFileSync(shellProfile, content + exportLine);
|
||
}
|
||
} else {
|
||
// 回退到系统级 (需要管理员权限)
|
||
const pathFile = `/etc/paths.d/clawx`;
|
||
await privilegeManager.execAsAdmin(
|
||
`echo "${newPath}" >> ${pathFile}`,
|
||
{ reason: '配置系统 PATH 环境变量' }
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Windows: 修改用户环境变量 (通过注册表)
|
||
*/
|
||
private async addToPathWindows(newPath: string): Promise<void> {
|
||
// 使用 PowerShell 修改用户级 PATH (无需管理员权限)
|
||
const command = `
|
||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||
if ($currentPath -notlike "*${newPath}*") {
|
||
[Environment]::SetEnvironmentVariable("Path", "${newPath};$currentPath", "User")
|
||
}
|
||
`;
|
||
|
||
await execAsync(`powershell -Command "${command}"`);
|
||
|
||
// 通知系统环境变量已更改
|
||
await execAsync('setx CLAWX_PATH_UPDATED 1');
|
||
}
|
||
|
||
/**
|
||
* Linux: 添加到 shell profile
|
||
*/
|
||
private async addToPathLinux(newPath: string): Promise<void> {
|
||
const shellProfile = this.getShellProfile();
|
||
|
||
if (shellProfile) {
|
||
const content = existsSync(shellProfile)
|
||
? readFileSync(shellProfile, 'utf-8')
|
||
: '';
|
||
|
||
if (!content.includes(newPath)) {
|
||
const exportLine = `\nexport PATH="${newPath}:$PATH"\n`;
|
||
writeFileSync(shellProfile, content + exportLine);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前用户的 shell profile 文件
|
||
*/
|
||
private getShellProfile(): string | null {
|
||
const home = homedir();
|
||
const shell = process.env.SHELL || '';
|
||
|
||
if (shell.includes('zsh')) {
|
||
return join(home, '.zshrc');
|
||
} else if (shell.includes('bash')) {
|
||
const bashProfile = join(home, '.bash_profile');
|
||
const bashrc = join(home, '.bashrc');
|
||
return existsSync(bashProfile) ? bashProfile : bashrc;
|
||
} else if (shell.includes('fish')) {
|
||
return join(home, '.config/fish/config.fish');
|
||
}
|
||
|
||
// 默认
|
||
return join(home, '.profile');
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2.8.5 设置页面 UI
|
||
|
||
```typescript
|
||
// src/pages/Settings/ProviderSettings.tsx
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { Eye, EyeOff, Check, AlertCircle } from 'lucide-react';
|
||
|
||
interface ApiKeyInputProps {
|
||
provider: string;
|
||
label: string;
|
||
placeholder: string;
|
||
hasKey: boolean;
|
||
onSave: (key: string) => Promise<void>;
|
||
onDelete: () => Promise<void>;
|
||
}
|
||
|
||
function ApiKeyInput({ provider, label, placeholder, hasKey, onSave, onDelete }: ApiKeyInputProps) {
|
||
const [value, setValue] = useState('');
|
||
const [showKey, setShowKey] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const handleSave = async () => {
|
||
if (!value.trim()) return;
|
||
|
||
setSaving(true);
|
||
setError(null);
|
||
|
||
try {
|
||
await onSave(value);
|
||
setValue(''); // 清空输入,显示"已配置"状态
|
||
} catch (e: any) {
|
||
setError(e.message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<Label className="text-sm font-medium">{label}</Label>
|
||
|
||
{hasKey ? (
|
||
// 已配置状态
|
||
<div className="flex items-center gap-2 p-3 rounded-lg border bg-green-50 dark:bg-green-950 border-green-200">
|
||
<Check className="w-4 h-4 text-green-600" />
|
||
<span className="text-sm text-green-700 dark:text-green-400">已配置</span>
|
||
<span className="text-sm text-muted-foreground">••••••••••••</span>
|
||
<div className="flex-1" />
|
||
<Button variant="ghost" size="sm" onClick={onDelete}>
|
||
删除
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => setShowKey(true)}>
|
||
重新配置
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
// 未配置状态
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<Input
|
||
type={showKey ? 'text' : 'password'}
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
placeholder={placeholder}
|
||
className="pr-10"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute right-0 top-0 h-full"
|
||
onClick={() => setShowKey(!showKey)}
|
||
>
|
||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
<Button onClick={handleSave} disabled={saving || !value.trim()}>
|
||
{saving ? '保存中...' : '保存'}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="flex items-center gap-2 text-sm text-red-600">
|
||
<AlertCircle className="w-4 h-4" />
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
API Key 将安全存储在系统密钥链中,不会以明文保存。
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ProviderSettings() {
|
||
const [config, setConfig] = useState<any>(null);
|
||
|
||
useEffect(() => {
|
||
// 从主进程获取配置
|
||
window.electron.ipcRenderer.invoke('env:getConfig').then(setConfig);
|
||
}, []);
|
||
|
||
const handleSaveApiKey = async (provider: string, key: string) => {
|
||
await window.electron.ipcRenderer.invoke('env:setApiKey', provider, key);
|
||
// 刷新配置
|
||
const newConfig = await window.electron.ipcRenderer.invoke('env:getConfig');
|
||
setConfig(newConfig);
|
||
};
|
||
|
||
const handleDeleteApiKey = async (provider: string) => {
|
||
await window.electron.ipcRenderer.invoke('env:deleteApiKey', provider);
|
||
const newConfig = await window.electron.ipcRenderer.invoke('env:getConfig');
|
||
setConfig(newConfig);
|
||
};
|
||
|
||
if (!config) return <LoadingSpinner />;
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>AI 模型配置</CardTitle>
|
||
<CardDescription>
|
||
配置您的 AI 服务提供商 API Key
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<ApiKeyInput
|
||
provider="anthropic"
|
||
label="Anthropic (Claude)"
|
||
placeholder="sk-ant-api03-..."
|
||
hasKey={!!config.apiKeys.anthropic}
|
||
onSave={(key) => handleSaveApiKey('anthropic', key)}
|
||
onDelete={() => handleDeleteApiKey('anthropic')}
|
||
/>
|
||
|
||
<Separator />
|
||
|
||
<ApiKeyInput
|
||
provider="openai"
|
||
label="OpenAI (GPT)"
|
||
placeholder="sk-..."
|
||
hasKey={!!config.apiKeys.openai}
|
||
onSave={(key) => handleSaveApiKey('openai', key)}
|
||
onDelete={() => handleDeleteApiKey('openai')}
|
||
/>
|
||
|
||
<Separator />
|
||
|
||
<ApiKeyInput
|
||
provider="google"
|
||
label="Google (Gemini)"
|
||
placeholder="AIza..."
|
||
hasKey={!!config.apiKeys.google}
|
||
onSave={(key) => handleSaveApiKey('google', key)}
|
||
onDelete={() => handleDeleteApiKey('google')}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 2.8.6 安装向导集成
|
||
|
||
```typescript
|
||
// src/pages/Setup/ProviderStep.tsx
|
||
|
||
export function ProviderStep({ onNext, onBack }: StepProps) {
|
||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||
const [apiKey, setApiKey] = useState('');
|
||
const [validating, setValidating] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const providers = [
|
||
{ id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖' },
|
||
{ id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚' },
|
||
{ id: 'google', name: 'Google', model: 'Gemini', icon: '🔷' },
|
||
];
|
||
|
||
const handleNext = async () => {
|
||
if (!apiKey.trim()) {
|
||
setError('请输入 API Key');
|
||
return;
|
||
}
|
||
|
||
setValidating(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 验证 API Key 是否有效
|
||
const valid = await window.electron.ipcRenderer.invoke(
|
||
'provider:validateKey',
|
||
selectedProvider,
|
||
apiKey
|
||
);
|
||
|
||
if (!valid) {
|
||
setError('API Key 无效,请检查后重试');
|
||
return;
|
||
}
|
||
|
||
// 安全存储 API Key
|
||
await window.electron.ipcRenderer.invoke(
|
||
'env:setApiKey',
|
||
selectedProvider,
|
||
apiKey
|
||
);
|
||
|
||
onNext({ provider: selectedProvider });
|
||
} catch (e: any) {
|
||
setError(e.message);
|
||
} finally {
|
||
setValidating(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="space-y-4">
|
||
<Label>选择 AI 服务提供商</Label>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{providers.map((p) => (
|
||
<Card
|
||
key={p.id}
|
||
className={cn(
|
||
'cursor-pointer transition-all p-4',
|
||
selectedProvider === p.id && 'ring-2 ring-primary'
|
||
)}
|
||
onClick={() => setSelectedProvider(p.id)}
|
||
>
|
||
<div className="text-center">
|
||
<span className="text-3xl">{p.icon}</span>
|
||
<p className="font-medium mt-2">{p.name}</p>
|
||
<p className="text-sm text-muted-foreground">{p.model}</p>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>API Key</Label>
|
||
<Input
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
placeholder={`输入您的 ${providers.find(p => p.id === selectedProvider)?.name} API Key`}
|
||
/>
|
||
{error && (
|
||
<p className="text-sm text-red-600">{error}</p>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
您的 API Key 将安全存储在系统密钥链中,不会上传到任何服务器。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex justify-between">
|
||
<Button variant="ghost" onClick={onBack}>上一步</Button>
|
||
<Button onClick={handleNext} disabled={validating}>
|
||
{validating ? '验证中...' : '下一步'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、UI/UX 设计规范
|
||
|
||
### 3.1 设计原则
|
||
|
||
| 原则 | 描述 |
|
||
|------|------|
|
||
| **简洁优先** | 隐藏复杂性,暴露必要功能 |
|
||
| **渐进式披露** | 基础功能易达,高级功能可探索 |
|
||
| **一致性** | 跨页面/组件保持视觉和交互一致 |
|
||
| **响应式反馈** | 所有操作有即时视觉反馈 |
|
||
| **原生感** | 遵循各平台设计语言 |
|
||
|
||
### 3.2 色彩系统
|
||
|
||
```css
|
||
/* src/styles/themes/tokens.css */
|
||
:root {
|
||
/* Primary */
|
||
--color-primary-50: #eff6ff;
|
||
--color-primary-500: #3b82f6;
|
||
--color-primary-600: #2563eb;
|
||
|
||
/* Semantic */
|
||
--color-success: #22c55e;
|
||
--color-warning: #f59e0b;
|
||
--color-error: #ef4444;
|
||
--color-info: #3b82f6;
|
||
|
||
/* Neutral (Light) */
|
||
--color-bg-primary: #ffffff;
|
||
--color-bg-secondary: #f8fafc;
|
||
--color-text-primary: #0f172a;
|
||
--color-text-secondary: #64748b;
|
||
--color-border: #e2e8f0;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--color-bg-primary: #0f172a;
|
||
--color-bg-secondary: #1e293b;
|
||
--color-text-primary: #f8fafc;
|
||
--color-text-secondary: #94a3b8;
|
||
--color-border: #334155;
|
||
}
|
||
```
|
||
|
||
### 3.3 核心页面线框图
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ ClawX ─ □ × │
|
||
├──────────┬──────────────────────────────────────────────┤
|
||
│ │ │
|
||
│ ● 概览 │ ┌─────────────┐ ┌─────────────┐ │
|
||
│ ○ 对话 │ │ Gateway │ │ Channels │ │
|
||
│ ○ 通道 │ │ ● 运行中 │ │ 3 已连接 │ │
|
||
│ ○ 技能 │ └─────────────┘ └─────────────┘ │
|
||
│ ○ 设置 │ │
|
||
│ ○ 定时任务│ ┌───────────────────────────────────────┐ │
|
||
│ │ │ 最近对话 │ │
|
||
│ │ ├───────────────────────────────────────┤ │
|
||
│ │ │ 📱 WhatsApp · 用户A · 2分钟前 │ │
|
||
│ │ │ 💬 Telegram · 用户B · 15分钟前 │ │
|
||
│ │ │ 💬 Discord · 用户C · 1小时前 │ │
|
||
│ │ └───────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ │ ┌───────────────────────────────────────┐ │
|
||
│ │ │ 快捷操作 │ │
|
||
│ │ │ [添加通道] [浏览技能] [查看日志] │ │
|
||
│ │ └───────────────────────────────────────┘ │
|
||
│ │ │
|
||
├──────────┴──────────────────────────────────────────────┤
|
||
│ Gateway: 运行中 · 3 通道 · 12 技能 · v2026.2.3 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.4 定时任务页面设计
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ ClawX ─ □ × │
|
||
├──────────┬──────────────────────────────────────────────┤
|
||
│ │ │
|
||
│ ○ 概览 │ 定时任务 [+ 新建任务] │
|
||
│ ○ 对话 │ │
|
||
│ ○ 通道 │ ┌───────────────────────────────────────┐ │
|
||
│ ○ 技能 │ │ 📋 每日天气播报 ● 已启用 │ │
|
||
│ ● 定时 │ │ ⏰ 每天 08:00 │ │
|
||
│ ○ 设置 │ │ 📍 发送到: WhatsApp - 家庭群 │ │
|
||
│ │ │ 上次执行: 今天 08:00 ✓ │ │
|
||
│ │ │ [编辑] [暂停] [删除] │ │
|
||
│ │ └───────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ │ ┌───────────────────────────────────────┐ │
|
||
│ │ │ 📊 周报汇总 ○ 已暂停 │ │
|
||
│ │ │ ⏰ 每周五 18:00 │ │
|
||
│ │ │ 📍 发送到: Telegram - 工作频道 │ │
|
||
│ │ │ 上次执行: 上周五 18:00 ✓ │ │
|
||
│ │ │ [编辑] [启用] [删除] │ │
|
||
│ │ └───────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ │ ┌───────────────────────────────────────┐ │
|
||
│ │ │ 🔔 服务器健康检查 ● 已启用 │ │
|
||
│ │ │ ⏰ 每 30 分钟 │ │
|
||
│ │ │ 📍 发送到: Discord - 运维通知 │ │
|
||
│ │ │ 上次执行: 10分钟前 ✓ │ │
|
||
│ │ │ [编辑] [暂停] [删除] │ │
|
||
│ │ └───────────────────────────────────────┘ │
|
||
│ │ │
|
||
├──────────┴──────────────────────────────────────────────┤
|
||
│ 3 个任务 · 2 个运行中 · 1 个暂停 │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 定时任务编辑器弹窗
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ 新建定时任务 [×] │
|
||
├─────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 任务名称 │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ 每日天气播报 │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 执行内容 (发送给 AI 的消息) │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ 请查询北京今天的天气,并生成一条适合发送到群里 │ │
|
||
│ │ 的天气播报消息,包含穿衣建议。 │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 执行时间 │
|
||
│ ┌──────────────┐ ┌────────────────────────────┐ │
|
||
│ │ ○ 每天 │ │ 08 : 00 │ │
|
||
│ │ ○ 每周 │ └────────────────────────────┘ │
|
||
│ │ ○ 每月 │ │
|
||
│ │ ○ 自定义Cron │ Cron: 0 8 * * * │
|
||
│ └──────────────┘ │
|
||
│ │
|
||
│ 发送到 │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ WhatsApp - 家庭群 ▼ │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ☑ 启用任务 │
|
||
│ │
|
||
│ [取消] [保存] │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 定时任务类型定义
|
||
|
||
```typescript
|
||
// src/types/cron.ts
|
||
|
||
export interface CronJob {
|
||
/** 任务 ID */
|
||
id: string;
|
||
|
||
/** 任务名称 */
|
||
name: string;
|
||
|
||
/** 发送给 AI 的消息内容 */
|
||
message: string;
|
||
|
||
/** Cron 表达式 */
|
||
schedule: string;
|
||
|
||
/** 目标通道 */
|
||
target: {
|
||
channelType: 'whatsapp' | 'telegram' | 'discord' | 'slack';
|
||
channelId: string;
|
||
channelName: string;
|
||
};
|
||
|
||
/** 是否启用 */
|
||
enabled: boolean;
|
||
|
||
/** 创建时间 */
|
||
createdAt: string;
|
||
|
||
/** 更新时间 */
|
||
updatedAt: string;
|
||
|
||
/** 上次执行信息 */
|
||
lastRun?: {
|
||
time: string;
|
||
success: boolean;
|
||
error?: string;
|
||
};
|
||
|
||
/** 下次执行时间 */
|
||
nextRun?: string;
|
||
}
|
||
|
||
export interface CronJobCreateInput {
|
||
name: string;
|
||
message: string;
|
||
schedule: string;
|
||
target: CronJob['target'];
|
||
enabled?: boolean;
|
||
}
|
||
|
||
export interface CronJobUpdateInput {
|
||
name?: string;
|
||
message?: string;
|
||
schedule?: string;
|
||
target?: CronJob['target'];
|
||
enabled?: boolean;
|
||
}
|
||
```
|
||
|
||
#### 定时任务 Hook
|
||
|
||
```typescript
|
||
// src/hooks/useCron.ts
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '@/types/cron';
|
||
|
||
export function useCron() {
|
||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 加载任务列表
|
||
const fetchJobs = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const result = await window.electron.ipcRenderer.invoke('cron:list');
|
||
setJobs(result);
|
||
setError(null);
|
||
} catch (e: any) {
|
||
setError(e.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchJobs();
|
||
|
||
// 监听任务更新事件
|
||
const handleUpdate = (_: any, updatedJobs: CronJob[]) => {
|
||
setJobs(updatedJobs);
|
||
};
|
||
|
||
window.electron.ipcRenderer.on('cron:updated', handleUpdate);
|
||
|
||
return () => {
|
||
window.electron.ipcRenderer.off('cron:updated', handleUpdate);
|
||
};
|
||
}, [fetchJobs]);
|
||
|
||
// 创建任务
|
||
const createJob = async (input: CronJobCreateInput): Promise<CronJob> => {
|
||
const job = await window.electron.ipcRenderer.invoke('cron:create', input);
|
||
await fetchJobs();
|
||
return job;
|
||
};
|
||
|
||
// 更新任务
|
||
const updateJob = async (id: string, input: CronJobUpdateInput): Promise<void> => {
|
||
await window.electron.ipcRenderer.invoke('cron:update', id, input);
|
||
await fetchJobs();
|
||
};
|
||
|
||
// 删除任务
|
||
const deleteJob = async (id: string): Promise<void> => {
|
||
await window.electron.ipcRenderer.invoke('cron:delete', id);
|
||
await fetchJobs();
|
||
};
|
||
|
||
// 启用/禁用任务
|
||
const toggleJob = async (id: string, enabled: boolean): Promise<void> => {
|
||
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled);
|
||
await fetchJobs();
|
||
};
|
||
|
||
// 手动触发任务
|
||
const triggerJob = async (id: string): Promise<void> => {
|
||
await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||
};
|
||
|
||
return {
|
||
jobs,
|
||
loading,
|
||
error,
|
||
createJob,
|
||
updateJob,
|
||
deleteJob,
|
||
toggleJob,
|
||
triggerJob,
|
||
refresh: fetchJobs,
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 定时任务页面组件
|
||
|
||
```typescript
|
||
// src/pages/Cron/index.tsx
|
||
|
||
import { useState } from 'react';
|
||
import { useCron } from '@/hooks/useCron';
|
||
import { CronJobCard } from './CronJobCard';
|
||
import { CronEditor } from './CronEditor';
|
||
import { Plus, Clock } from 'lucide-react';
|
||
|
||
export function CronPage() {
|
||
const { jobs, loading, createJob, updateJob, deleteJob, toggleJob } = useCron();
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
|
||
|
||
const activeJobs = jobs.filter(j => j.enabled);
|
||
const pausedJobs = jobs.filter(j => !j.enabled);
|
||
|
||
const handleCreate = () => {
|
||
setEditingJob(null);
|
||
setEditorOpen(true);
|
||
};
|
||
|
||
const handleEdit = (job: CronJob) => {
|
||
setEditingJob(job);
|
||
setEditorOpen(true);
|
||
};
|
||
|
||
const handleSave = async (input: CronJobCreateInput) => {
|
||
if (editingJob) {
|
||
await updateJob(editingJob.id, input);
|
||
} else {
|
||
await createJob(input);
|
||
}
|
||
setEditorOpen(false);
|
||
};
|
||
|
||
if (loading) return <LoadingSpinner />;
|
||
|
||
return (
|
||
<div className="p-6 space-y-6">
|
||
{/* 页头 */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold">定时任务</h1>
|
||
<p className="text-muted-foreground">
|
||
设置自动执行的 AI 任务,按计划发送消息
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleCreate}>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新建任务
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 统计卡片 */}
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-3 rounded-full bg-primary/10">
|
||
<Clock className="w-6 h-6 text-primary" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{jobs.length}</p>
|
||
<p className="text-sm text-muted-foreground">总任务数</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-3 rounded-full bg-green-100">
|
||
<Play className="w-6 h-6 text-green-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{activeJobs.length}</p>
|
||
<p className="text-sm text-muted-foreground">运行中</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-3 rounded-full bg-yellow-100">
|
||
<Pause className="w-6 h-6 text-yellow-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold">{pausedJobs.length}</p>
|
||
<p className="text-sm text-muted-foreground">已暂停</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 任务列表 */}
|
||
{jobs.length === 0 ? (
|
||
<Card className="p-12 text-center">
|
||
<Clock className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-medium mb-2">暂无定时任务</h3>
|
||
<p className="text-muted-foreground mb-4">
|
||
创建您的第一个定时任务,让 AI 自动为您工作
|
||
</p>
|
||
<Button onClick={handleCreate}>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
新建任务
|
||
</Button>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{jobs.map(job => (
|
||
<CronJobCard
|
||
key={job.id}
|
||
job={job}
|
||
onEdit={() => handleEdit(job)}
|
||
onDelete={() => deleteJob(job.id)}
|
||
onToggle={(enabled) => toggleJob(job.id, enabled)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 编辑器弹窗 */}
|
||
<CronEditor
|
||
open={editorOpen}
|
||
onClose={() => setEditorOpen(false)}
|
||
onSave={handleSave}
|
||
initialData={editingJob}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### Cron 表达式选择器
|
||
|
||
```typescript
|
||
// src/pages/Cron/CronSchedulePicker.tsx
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface CronSchedulePickerProps {
|
||
value: string;
|
||
onChange: (cron: string) => void;
|
||
}
|
||
|
||
type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';
|
||
|
||
export function CronSchedulePicker({ value, onChange }: CronSchedulePickerProps) {
|
||
const [type, setType] = useState<ScheduleType>('daily');
|
||
const [time, setTime] = useState('08:00');
|
||
const [weekday, setWeekday] = useState(1); // 周一
|
||
const [monthday, setMonthday] = useState(1);
|
||
const [interval, setInterval] = useState(30); // 分钟
|
||
const [customCron, setCustomCron] = useState(value);
|
||
|
||
// 解析现有 cron 表达式
|
||
useEffect(() => {
|
||
// 简单解析,实际可用 cron-parser 库
|
||
if (value.match(/^\d+ \d+ \* \* \*$/)) {
|
||
setType('daily');
|
||
const [min, hour] = value.split(' ');
|
||
setTime(`${hour.padStart(2, '0')}:${min.padStart(2, '0')}`);
|
||
}
|
||
// ... 其他模式解析
|
||
}, []);
|
||
|
||
// 生成 cron 表达式
|
||
useEffect(() => {
|
||
let cron = '';
|
||
const [hour, min] = time.split(':');
|
||
|
||
switch (type) {
|
||
case 'daily':
|
||
cron = `${parseInt(min)} ${parseInt(hour)} * * *`;
|
||
break;
|
||
case 'weekly':
|
||
cron = `${parseInt(min)} ${parseInt(hour)} * * ${weekday}`;
|
||
break;
|
||
case 'monthly':
|
||
cron = `${parseInt(min)} ${parseInt(hour)} ${monthday} * *`;
|
||
break;
|
||
case 'interval':
|
||
cron = `*/${interval} * * * *`;
|
||
break;
|
||
case 'custom':
|
||
cron = customCron;
|
||
break;
|
||
}
|
||
|
||
if (cron !== value) {
|
||
onChange(cron);
|
||
}
|
||
}, [type, time, weekday, monthday, interval, customCron]);
|
||
|
||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 类型选择 */}
|
||
<RadioGroup value={type} onValueChange={(v) => setType(v as ScheduleType)}>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="daily" id="daily" />
|
||
<Label htmlFor="daily">每天</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="weekly" id="weekly" />
|
||
<Label htmlFor="weekly">每周</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="monthly" id="monthly" />
|
||
<Label htmlFor="monthly">每月</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="interval" id="interval" />
|
||
<Label htmlFor="interval">间隔执行</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="custom" id="custom" />
|
||
<Label htmlFor="custom">自定义 Cron</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
|
||
{/* 时间选择 */}
|
||
{(type === 'daily' || type === 'weekly' || type === 'monthly') && (
|
||
<div className="flex items-center gap-2">
|
||
<Label>执行时间:</Label>
|
||
<Input
|
||
type="time"
|
||
value={time}
|
||
onChange={(e) => setTime(e.target.value)}
|
||
className="w-32"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 周几选择 */}
|
||
{type === 'weekly' && (
|
||
<div className="flex items-center gap-2">
|
||
<Label>星期:</Label>
|
||
<Select value={String(weekday)} onValueChange={(v) => setWeekday(parseInt(v))}>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{weekdays.map((day, i) => (
|
||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 日期选择 */}
|
||
{type === 'monthly' && (
|
||
<div className="flex items-center gap-2">
|
||
<Label>日期:</Label>
|
||
<Select value={String(monthday)} onValueChange={(v) => setMonthday(parseInt(v))}>
|
||
<SelectTrigger className="w-32">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
|
||
<SelectItem key={day} value={String(day)}>{day} 日</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 间隔选择 */}
|
||
{type === 'interval' && (
|
||
<div className="flex items-center gap-2">
|
||
<Label>每隔:</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={1440}
|
||
value={interval}
|
||
onChange={(e) => setInterval(parseInt(e.target.value))}
|
||
className="w-20"
|
||
/>
|
||
<span>分钟</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 自定义 Cron */}
|
||
{type === 'custom' && (
|
||
<div className="space-y-2">
|
||
<Input
|
||
value={customCron}
|
||
onChange={(e) => setCustomCron(e.target.value)}
|
||
placeholder="分 时 日 月 周 (如: 0 8 * * 1-5)"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Cron 表达式格式: 分钟(0-59) 小时(0-23) 日(1-31) 月(1-12) 周(0-6)
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 预览 */}
|
||
<div className="p-3 rounded-lg bg-muted">
|
||
<p className="text-sm">
|
||
<span className="text-muted-foreground">Cron 表达式: </span>
|
||
<code className="font-mono">{value}</code>
|
||
</p>
|
||
<p className="text-sm">
|
||
<span className="text-muted-foreground">下次执行: </span>
|
||
{getNextRunTime(value)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getNextRunTime(cron: string): string {
|
||
// 使用 croner 或类似库计算下次执行时间
|
||
// 这里简化处理
|
||
return '今天 08:00';
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、版本规划
|
||
|
||
### 4.1 版本号策略
|
||
|
||
```
|
||
ClawX 版本: X.Y.Z[-prerelease]
|
||
│ │ │
|
||
│ │ └── Patch: Bug 修复、性能优化
|
||
│ └──── Minor: 新功能、新技能包、UI 改进
|
||
└────── Major: 重大变更、不兼容更新
|
||
|
||
示例:
|
||
- 1.0.0 首个稳定版
|
||
- 1.1.0 新增技能市场功能
|
||
- 1.1.1 修复 Windows 安装问题
|
||
- 2.0.0 UI 重构、新架构
|
||
- 1.2.0-beta.1 下一版本测试版
|
||
```
|
||
|
||
### 4.2 OpenClaw 兼容性矩阵
|
||
|
||
| ClawX 版本 | OpenClaw 版本 | Node.js 版本 | 备注 |
|
||
|------------|---------------|--------------|------|
|
||
| 1.0.x | 2026.2.x | 22.x | 首发版本 |
|
||
| 1.1.x | 2026.3.x | 22.x | 功能增强 |
|
||
| 2.0.x | 2026.6.x | 24.x | 大版本升级 |
|
||
|
||
### 4.3 里程碑规划
|
||
|
||
#### 🚀 v0.1.0 - Alpha (内部测试)
|
||
|
||
**目标**: 核心架构验证
|
||
|
||
| 任务 | 优先级 | 状态 |
|
||
|------|--------|------|
|
||
| Electron + React 项目骨架 | P0 | ⬜ |
|
||
| Gateway 进程管理 | P0 | ⬜ |
|
||
| 基础 UI 框架 (侧边栏/布局) | P0 | ⬜ |
|
||
| WebSocket 通信层 | P0 | ⬜ |
|
||
| 状态管理 (Zustand) | P1 | ⬜ |
|
||
|
||
**交付物**:
|
||
- 可运行的桌面应用
|
||
- 能启动/停止 Gateway
|
||
- 基础 Dashboard 页面
|
||
|
||
---
|
||
|
||
#### 🎯 v0.5.0 - Beta (公开测试)
|
||
|
||
**目标**: 安装向导 MVP
|
||
|
||
| 任务 | 优先级 | 状态 |
|
||
|------|--------|------|
|
||
| 安装向导 UI | P0 | ⬜ |
|
||
| Node.js 自动检测/安装 | P0 | ⬜ |
|
||
| openclaw npm 安装 | P0 | ⬜ |
|
||
| Provider 配置 (API Key) | P0 | ⬜ |
|
||
| 错误处理与提示 | P1 | ⬜ |
|
||
|
||
> **注意**: 通道连接功能 (WhatsApp/Telegram 等) 已从安装向导移至 Settings > Channels 页面。
|
||
> 用户可在完成初始设置后,根据需要自行配置消息通道,降低首次使用门槛。
|
||
|
||
**交付物**:
|
||
- 简化版安装向导流程 (不含通道连接)
|
||
- 支持 macOS (Apple Silicon + Intel)
|
||
- 可配置 Anthropic/OpenAI/OpenRouter
|
||
|
||
---
|
||
|
||
#### 📦 v1.0.0 - Stable (首个正式版)
|
||
|
||
**目标**: 生产可用
|
||
|
||
| 任务 | 优先级 | 状态 |
|
||
|------|--------|------|
|
||
| 完整 Dashboard | P0 | ⬜ |
|
||
| 通道管理页面 | P0 | ⬜ |
|
||
| 对话界面 | P0 | ⬜ |
|
||
| 技能浏览/启用 | P0 | ⬜ |
|
||
| 设置页面 | P0 | ⬜ |
|
||
| 预装技能包选择 | P1 | ⬜ |
|
||
| 自动更新 (Sparkle/electron-updater) | P1 | ⬜ |
|
||
| Windows 支持 | P1 | ⬜ |
|
||
| 深色模式 | P2 | ⬜ |
|
||
| 崩溃报告 | P2 | ⬜ |
|
||
|
||
**交付物**:
|
||
- macOS + Windows 安装包
|
||
- 自动更新能力
|
||
- 用户文档
|
||
|
||
---
|
||
|
||
#### 🌟 v1.1.0 - 功能增强
|
||
|
||
**目标**: 技能生态
|
||
|
||
| 任务 | 优先级 | 状态 |
|
||
|------|--------|------|
|
||
| 技能市场 UI | P0 | ⬜ |
|
||
| 在线技能安装 | P0 | ⬜ |
|
||
| 技能配置界面 | P1 | ⬜ |
|
||
| 技能使用统计 | P2 | ⬜ |
|
||
| Linux 支持 | P2 | ⬜ |
|
||
|
||
---
|
||
|
||
#### 🚀 v2.0.0 - 重大升级
|
||
|
||
**目标**: 多 Agent / 高级功能
|
||
|
||
| 任务 | 优先级 | 状态 |
|
||
|------|--------|------|
|
||
| 多 Agent 支持 | P0 | ⬜ |
|
||
| 工作流编排 | P1 | ⬜ |
|
||
| 插件 SDK | P1 | ⬜ |
|
||
| 自定义主题 | P2 | ⬜ |
|
||
| 性能监控面板 | P2 | ⬜ |
|
||
|
||
---
|
||
|
||
## 五、开发规范
|
||
|
||
### 5.1 代码风格
|
||
|
||
```typescript
|
||
// ✅ 命名约定
|
||
const MY_CONSTANT = 'value'; // 常量: SCREAMING_SNAKE_CASE
|
||
function getUserData() {} // 函数: camelCase
|
||
class GatewayManager {} // 类: PascalCase
|
||
interface ChannelConfig {} // 接口: PascalCase
|
||
type StatusType = 'running'; // 类型: PascalCase
|
||
|
||
// ✅ 文件命名
|
||
// 组件: PascalCase.tsx
|
||
// Dashboard.tsx, SkillCard.tsx
|
||
|
||
// 工具/hooks: kebab-case.ts
|
||
// gateway-manager.ts, use-gateway.ts
|
||
|
||
// ✅ 目录命名
|
||
// kebab-case
|
||
// src/pages/skill-market/
|
||
|
||
// ✅ React 组件
|
||
export function SkillCard({ skill, onSelect }: SkillCardProps) {
|
||
// hooks 在顶部
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
// handlers
|
||
const handleClick = () => { /* ... */ };
|
||
|
||
// render
|
||
return (
|
||
<Card onClick={handleClick}>
|
||
{/* ... */}
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 5.2 Git 提交规范
|
||
|
||
```
|
||
<type>(<scope>): <subject>
|
||
|
||
<body>
|
||
|
||
<footer>
|
||
```
|
||
|
||
**Type**:
|
||
- `feat`: 新功能
|
||
- `fix`: Bug 修复
|
||
- `docs`: 文档
|
||
- `style`: 格式调整
|
||
- `refactor`: 重构
|
||
- `perf`: 性能优化
|
||
- `test`: 测试
|
||
- `chore`: 构建/工具
|
||
|
||
**示例**:
|
||
```
|
||
feat(setup): add Node.js auto-installer for Windows
|
||
|
||
- Detect existing Node.js installation
|
||
- Download and install via winget if missing
|
||
- Show progress indicator during installation
|
||
|
||
Closes #123
|
||
```
|
||
|
||
### 5.3 测试策略
|
||
|
||
```
|
||
/\
|
||
/ \ E2E 测试 (10%)
|
||
/ \ - Playwright
|
||
/──────\ - 完整用户流程
|
||
/ \ - CI 慢速运行
|
||
/──────────\ 集成测试 (20%)
|
||
/ \ - Gateway 通信
|
||
/ \ - IPC 调用
|
||
/────────────────\ 单元测试 (70%)
|
||
/ \ - Vitest
|
||
\ - 组件/函数
|
||
```
|
||
|
||
---
|
||
|
||
## 六、发布流程
|
||
|
||
### 6.1 发布清单
|
||
|
||
```bash
|
||
# 1. 版本更新
|
||
pnpm version <major|minor|patch>
|
||
|
||
# 2. 更新 Changelog
|
||
# 手动编辑 CHANGELOG.md
|
||
|
||
# 3. 构建检查
|
||
pnpm lint && pnpm test && pnpm build
|
||
|
||
# 4. 打包各平台
|
||
pnpm package:mac # macOS
|
||
pnpm package:win # Windows
|
||
pnpm package:linux # Linux
|
||
|
||
# 5. 签名 & 公证 (macOS)
|
||
pnpm notarize
|
||
|
||
# 6. 创建 Release
|
||
git tag v1.0.0
|
||
git push origin v1.0.0
|
||
# GitHub Actions 自动发布
|
||
```
|
||
|
||
### 6.2 更新通道
|
||
|
||
| 通道 | 用途 | 更新频率 |
|
||
|------|------|----------|
|
||
| `stable` | 正式版 | 每月/按需 |
|
||
| `beta` | 测试版 | 每周 |
|
||
| `dev` | 开发版 | 每次提交 |
|
||
|
||
---
|
||
|
||
## 七、附录
|
||
|
||
### 7.1 参考资源
|
||
|
||
| 资源 | 链接 |
|
||
|------|------|
|
||
| OpenClaw 仓库 | https://github.com/openclaw/openclaw |
|
||
| OpenClaw 文档 | https://docs.openclaw.ai |
|
||
| Electron 文档 | https://www.electronjs.org/docs |
|
||
| shadcn/ui | https://ui.shadcn.com |
|
||
| Tailwind CSS | https://tailwindcss.com |
|
||
|
||
### 7.2 环境变量
|
||
|
||
```bash
|
||
# .env.example
|
||
|
||
# OpenClaw 配置
|
||
OPENCLAW_GATEWAY_PORT=18789
|
||
|
||
# 开发配置
|
||
VITE_DEV_SERVER_PORT=5173
|
||
|
||
# 发布配置 (CI)
|
||
APPLE_ID=your@email.com
|
||
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||
CSC_LINK=path/to/certificate.p12
|
||
CSC_KEY_PASSWORD=certificate_password
|
||
GH_TOKEN=github_personal_access_token
|
||
```
|
||
|
||
### 7.3 常用命令
|
||
|
||
```bash
|
||
# 开发
|
||
pnpm dev # 启动开发服务器
|
||
pnpm dev:main # 仅启动 Electron 主进程
|
||
pnpm dev:renderer # 仅启动 React 开发服务器
|
||
|
||
# 构建
|
||
pnpm build # 构建生产版本
|
||
pnpm build:main # 构建主进程
|
||
pnpm build:renderer # 构建渲染进程
|
||
|
||
# 打包
|
||
pnpm package # 打包当前平台
|
||
pnpm package:mac # 打包 macOS
|
||
pnpm package:win # 打包 Windows
|
||
pnpm package:linux # 打包 Linux
|
||
pnpm package:all # 打包所有平台
|
||
|
||
# 测试
|
||
pnpm test # 运行单元测试
|
||
pnpm test:e2e # 运行 E2E 测试
|
||
pnpm test:coverage # 生成覆盖率报告
|
||
|
||
# 代码质量
|
||
pnpm lint # 检查代码规范
|
||
pnpm lint:fix # 自动修复
|
||
pnpm typecheck # TypeScript 类型检查
|
||
```
|
||
|
||
---
|