feat(core): initialize project skeleton with Electron + React + TypeScript
Set up the complete project foundation for ClawX, a graphical AI assistant: - Electron main process with IPC handlers, menu, tray, and gateway management - React renderer with routing, layout components, and page scaffolding - Zustand state management for gateway, settings, channels, skills, chat, and cron - shadcn/ui components with Tailwind CSS and CSS variable theming - Build tooling with Vite, electron-builder, and TypeScript configuration - Testing setup with Vitest and Playwright - Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ClawX Environment Variables
|
||||||
|
|
||||||
|
# OpenClaw Gateway Configuration
|
||||||
|
OPENCLAW_GATEWAY_PORT=18789
|
||||||
|
|
||||||
|
# Development Configuration
|
||||||
|
VITE_DEV_SERVER_PORT=5173
|
||||||
|
|
||||||
|
# Release Configuration (CI/CD)
|
||||||
|
# Apple Developer Credentials
|
||||||
|
APPLE_ID=your@email.com
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
APPLE_TEAM_ID=XXXXXXXXXX
|
||||||
|
|
||||||
|
# Code Signing Certificate
|
||||||
|
CSC_LINK=path/to/certificate.p12
|
||||||
|
CSC_KEY_PASSWORD=certificate_password
|
||||||
|
|
||||||
|
# GitHub Token for releases
|
||||||
|
GH_TOKEN=github_personal_access_token
|
||||||
20
.eslintrc.cjs
Normal file
20
.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true, node: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', 'dist-electron', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
},
|
||||||
|
};
|
||||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
*.dmg
|
||||||
|
*.exe
|
||||||
|
*.AppImage
|
||||||
|
*.deb
|
||||||
|
*.rpm
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.p12
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
123
README.md
123
README.md
@@ -1 +1,124 @@
|
|||||||
# ClawX
|
# ClawX
|
||||||
|
|
||||||
|
> Graphical AI Assistant based on OpenClaw
|
||||||
|
|
||||||
|
ClawX is a modern desktop application that provides a beautiful graphical interface for OpenClaw, making AI assistants accessible to everyone without command-line knowledge.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎯 **Zero CLI Required** - Complete all installation, configuration, and usage through GUI
|
||||||
|
- 🎨 **Modern UI** - Beautiful, intuitive desktop application interface
|
||||||
|
- 📦 **Ready to Use** - Pre-installed skill bundles, ready immediately
|
||||||
|
- 🖥️ **Cross-Platform** - Unified experience on macOS / Windows / Linux
|
||||||
|
- 🔄 **Seamless Integration** - Fully compatible with OpenClaw ecosystem
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Electron 33+
|
||||||
|
- **Frontend**: React 19 + TypeScript
|
||||||
|
- **UI Components**: shadcn/ui + Tailwind CSS
|
||||||
|
- **State Management**: Zustand
|
||||||
|
- **Build Tools**: Vite + electron-builder
|
||||||
|
- **Testing**: Vitest + Playwright
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- pnpm (recommended) or npm
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/clawx/clawx.git
|
||||||
|
cd clawx
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm dev # Start development server with hot reload
|
||||||
|
pnpm build # Build for production
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pnpm test # Run unit tests
|
||||||
|
pnpm test:e2e # Run E2E tests
|
||||||
|
pnpm test:coverage # Generate coverage report
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
pnpm lint # Run ESLint
|
||||||
|
pnpm lint:fix # Fix linting issues
|
||||||
|
pnpm typecheck # TypeScript type checking
|
||||||
|
|
||||||
|
# Packaging
|
||||||
|
pnpm package # Package for current platform
|
||||||
|
pnpm package:mac # Package for macOS
|
||||||
|
pnpm package:win # Package for Windows
|
||||||
|
pnpm package:linux # Package for Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
clawx/
|
||||||
|
├── electron/ # Electron main process
|
||||||
|
│ ├── main/ # Main process entry and handlers
|
||||||
|
│ ├── gateway/ # Gateway process management
|
||||||
|
│ ├── preload/ # Preload scripts
|
||||||
|
│ └── utils/ # Utilities
|
||||||
|
├── src/ # React renderer process
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── pages/ # Page components
|
||||||
|
│ ├── stores/ # Zustand state stores
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── types/ # TypeScript types
|
||||||
|
│ └── styles/ # Global styles
|
||||||
|
├── resources/ # Static resources
|
||||||
|
├── tests/ # Test files
|
||||||
|
└── build_process/ # Build documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
ClawX follows a dual-port architecture:
|
||||||
|
|
||||||
|
- **Port 23333**: ClawX GUI (default interface)
|
||||||
|
- **Port 18789**: OpenClaw Gateway (native management)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ClawX App │
|
||||||
|
│ ┌───────────────────────────┐ │
|
||||||
|
│ │ Electron Main Process │ │
|
||||||
|
│ │ - Window management │ │
|
||||||
|
│ │ - Gateway lifecycle │ │
|
||||||
|
│ │ - System integration │ │
|
||||||
|
│ └───────────────────────────┘ │
|
||||||
|
│ ┌───────────────────────────┐ │
|
||||||
|
│ │ React Renderer Process │ │
|
||||||
|
│ │ - Modern UI │ │
|
||||||
|
│ │ - WebSocket communication │ │
|
||||||
|
│ └───────────────────────────┘ │
|
||||||
|
└───────────────┬─────────────────┘
|
||||||
|
│ WebSocket (JSON-RPC)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ OpenClaw Gateway │
|
||||||
|
│ - Message channel management │
|
||||||
|
│ - AI Agent runtime │
|
||||||
|
│ - Skills/plugins system │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
108
build_process/commit_1_project_skeleton.md
Normal file
108
build_process/commit_1_project_skeleton.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Commit 1: Project Skeleton
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Initialize the ClawX project with Electron + React + TypeScript architecture, including all foundational components for v0.1.0 Alpha.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Project Configuration
|
||||||
|
- `package.json` - Project dependencies and scripts
|
||||||
|
- `tsconfig.json` / `tsconfig.node.json` - TypeScript configuration
|
||||||
|
- `vite.config.ts` - Vite bundler configuration with Electron plugins
|
||||||
|
- `tailwind.config.js` / `postcss.config.js` - Tailwind CSS setup
|
||||||
|
- `electron-builder.yml` - Multi-platform packaging configuration
|
||||||
|
- `.eslintrc.cjs` / `.prettierrc` - Code style configuration
|
||||||
|
- `vitest.config.ts` - Test framework configuration
|
||||||
|
- `.gitignore` / `.env.example` - Git and environment setup
|
||||||
|
|
||||||
|
### Electron Main Process (`electron/`)
|
||||||
|
- `main/index.ts` - Main process entry point
|
||||||
|
- `main/window.ts` - Window state management
|
||||||
|
- `main/tray.ts` - System tray functionality
|
||||||
|
- `main/menu.ts` - Application menu
|
||||||
|
- `main/ipc-handlers.ts` - IPC communication handlers
|
||||||
|
- `gateway/manager.ts` - Gateway process lifecycle management
|
||||||
|
- `gateway/client.ts` - Typed Gateway RPC client
|
||||||
|
- `gateway/protocol.ts` - JSON-RPC protocol definitions
|
||||||
|
- `preload/index.ts` - Context bridge for renderer
|
||||||
|
- `utils/config.ts` - Configuration constants
|
||||||
|
- `utils/logger.ts` - Logging utility
|
||||||
|
- `utils/paths.ts` - Path resolution helpers
|
||||||
|
- `utils/store.ts` - Persistent storage
|
||||||
|
|
||||||
|
### React Renderer (`src/`)
|
||||||
|
- `main.tsx` / `App.tsx` - Application entry and root component
|
||||||
|
- `styles/globals.css` - Global styles with CSS variables
|
||||||
|
|
||||||
|
#### Components
|
||||||
|
- `components/ui/` - shadcn/ui base components (Button, Card, Input, Badge, etc.)
|
||||||
|
- `components/layout/MainLayout.tsx` - Main application layout
|
||||||
|
- `components/layout/Sidebar.tsx` - Navigation sidebar
|
||||||
|
- `components/layout/Header.tsx` - Top header bar
|
||||||
|
- `components/common/` - Common components (LoadingSpinner, StatusBadge, ErrorBoundary)
|
||||||
|
|
||||||
|
#### Pages
|
||||||
|
- `pages/Dashboard/index.tsx` - Overview dashboard
|
||||||
|
- `pages/Chat/index.tsx` - Chat interface
|
||||||
|
- `pages/Channels/index.tsx` - Channel management
|
||||||
|
- `pages/Skills/index.tsx` - Skill browser
|
||||||
|
- `pages/Cron/index.tsx` - Scheduled tasks
|
||||||
|
- `pages/Settings/index.tsx` - Application settings
|
||||||
|
- `pages/Setup/index.tsx` - First-run setup wizard
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
- `stores/gateway.ts` - Gateway connection state
|
||||||
|
- `stores/settings.ts` - Application settings
|
||||||
|
- `stores/channels.ts` - Channel data
|
||||||
|
- `stores/skills.ts` - Skills data
|
||||||
|
- `stores/chat.ts` - Chat messages
|
||||||
|
- `stores/cron.ts` - Cron jobs
|
||||||
|
|
||||||
|
#### Types
|
||||||
|
- `types/electron.d.ts` - Electron API types
|
||||||
|
- `types/gateway.ts` - Gateway types
|
||||||
|
- `types/channel.ts` - Channel types
|
||||||
|
- `types/skill.ts` - Skill types
|
||||||
|
- `types/cron.ts` - Cron job types
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
- `resources/icons/.gitkeep` - Placeholder for app icons
|
||||||
|
- `resources/skills/bundles.json` - Predefined skill bundles
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/setup.ts` - Test environment setup
|
||||||
|
- `tests/unit/utils.test.ts` - Utility function tests
|
||||||
|
- `tests/unit/stores.test.ts` - Store tests
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Architecture Decisions
|
||||||
|
1. **Vite + Electron**: Using vite-plugin-electron for fast HMR during development
|
||||||
|
2. **Context Isolation**: All IPC communication through preload script with strict channel validation
|
||||||
|
3. **Zustand Stores**: Lightweight state management with persistence support
|
||||||
|
4. **shadcn/ui**: Customizable, accessible UI components based on Radix primitives
|
||||||
|
5. **Dual-port Architecture**: Separate ports for GUI (23333) and Gateway (18789)
|
||||||
|
|
||||||
|
### Key Features Implemented
|
||||||
|
- ✅ Electron main process with window management
|
||||||
|
- ✅ System tray integration
|
||||||
|
- ✅ Gateway process lifecycle management
|
||||||
|
- ✅ WebSocket communication layer
|
||||||
|
- ✅ JSON-RPC protocol support
|
||||||
|
- ✅ React router with all main pages
|
||||||
|
- ✅ Zustand state management
|
||||||
|
- ✅ Dark/Light theme support
|
||||||
|
- ✅ Responsive sidebar navigation
|
||||||
|
- ✅ Setup wizard flow
|
||||||
|
- ✅ Unit test setup with Vitest
|
||||||
|
|
||||||
|
## Version
|
||||||
|
v0.1.0-alpha
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Electron 33.3.0
|
||||||
|
- React 19.0.0
|
||||||
|
- TypeScript 5.7.2
|
||||||
|
- Vite 6.0.6
|
||||||
|
- Zustand 5.0.2
|
||||||
|
- Tailwind CSS 3.4.17
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
# Before:
|
# ClawX Build Process
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### Before:
|
||||||
* add `ClawX-项目架构与版本大纲.md`
|
* add `ClawX-项目架构与版本大纲.md`
|
||||||
|
|
||||||
# Plan:
|
### Completed:
|
||||||
|
* [commit_1] Project skeleton - Electron + React + TypeScript foundation (v0.1.0-alpha)
|
||||||
|
|
||||||
Initialize the project structure according to `ClawX-项目架构与版本大纲.md`, and add the fundamental dependencies as specified in the documentation and technology stack.
|
### Plan:
|
||||||
|
1. ~~Initialize project structure~~ ✅
|
||||||
|
2. Add Gateway process management refinements
|
||||||
|
3. Implement Setup wizard with actual functionality
|
||||||
|
4. Add Provider configuration (API Key management)
|
||||||
|
5. Implement Channel connection flows
|
||||||
|
6. Add auto-update functionality
|
||||||
|
7. Packaging and distribution setup
|
||||||
|
|
||||||
|
## Version Milestones
|
||||||
|
|
||||||
|
| Version | Status | Description |
|
||||||
|
|---------|--------|-------------|
|
||||||
|
| v0.1.0-alpha | ✅ Done | Core architecture, basic UI framework |
|
||||||
|
| v0.5.0-beta | Pending | Setup wizard MVP, Node.js installer |
|
||||||
|
| v1.0.0 | Pending | Production ready, all core features |
|
||||||
|
|||||||
81
electron-builder.yml
Normal file
81
electron-builder.yml
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
appId: app.clawx.desktop
|
||||||
|
productName: ClawX
|
||||||
|
copyright: Copyright © 2026 ClawX
|
||||||
|
compression: maximum
|
||||||
|
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
buildResources: resources
|
||||||
|
|
||||||
|
files:
|
||||||
|
- dist
|
||||||
|
- dist-electron
|
||||||
|
- package.json
|
||||||
|
|
||||||
|
extraResources:
|
||||||
|
- from: resources/
|
||||||
|
to: resources/
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
- provider: github
|
||||||
|
owner: clawx
|
||||||
|
repo: clawx
|
||||||
|
releaseType: release
|
||||||
|
|
||||||
|
mac:
|
||||||
|
category: public.app-category.productivity
|
||||||
|
icon: resources/icons/icon.icns
|
||||||
|
target:
|
||||||
|
- target: dmg
|
||||||
|
arch:
|
||||||
|
- universal
|
||||||
|
- target: zip
|
||||||
|
arch:
|
||||||
|
- universal
|
||||||
|
darkModeSupport: true
|
||||||
|
hardenedRuntime: true
|
||||||
|
gatekeeperAssess: false
|
||||||
|
entitlements: entitlements.mac.plist
|
||||||
|
entitlementsInherit: entitlements.mac.plist
|
||||||
|
|
||||||
|
dmg:
|
||||||
|
contents:
|
||||||
|
- type: file
|
||||||
|
x: 130
|
||||||
|
y: 220
|
||||||
|
- type: link
|
||||||
|
path: /Applications
|
||||||
|
x: 410
|
||||||
|
y: 220
|
||||||
|
|
||||||
|
win:
|
||||||
|
icon: resources/icons/icon.ico
|
||||||
|
target:
|
||||||
|
- target: nsis
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
publisherName: ClawX Inc.
|
||||||
|
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
deleteAppDataOnUninstall: false
|
||||||
|
differentialPackage: true
|
||||||
|
createDesktopShortcut: true
|
||||||
|
createStartMenuShortcut: true
|
||||||
|
|
||||||
|
linux:
|
||||||
|
icon: resources/icons
|
||||||
|
target:
|
||||||
|
- target: AppImage
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
- target: deb
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
category: Utility
|
||||||
|
maintainer: ClawX Team
|
||||||
186
electron/gateway/client.ts
Normal file
186
electron/gateway/client.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Gateway WebSocket Client
|
||||||
|
* Provides a typed interface for Gateway RPC calls
|
||||||
|
*/
|
||||||
|
import { GatewayManager } from './manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel types supported by OpenClaw
|
||||||
|
*/
|
||||||
|
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel status
|
||||||
|
*/
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
type: ChannelType;
|
||||||
|
name: string;
|
||||||
|
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||||
|
lastActivity?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill definition
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
configurable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat message
|
||||||
|
*/
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
channel?: string;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call in a message
|
||||||
|
*/
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway Client
|
||||||
|
* Typed wrapper around GatewayManager for making RPC calls
|
||||||
|
*/
|
||||||
|
export class GatewayClient {
|
||||||
|
constructor(private manager: GatewayManager) {}
|
||||||
|
|
||||||
|
// ==================== Channel Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all channels
|
||||||
|
*/
|
||||||
|
async listChannels(): Promise<Channel[]> {
|
||||||
|
return this.manager.rpc<Channel[]>('channels.list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get channel by ID
|
||||||
|
*/
|
||||||
|
async getChannel(channelId: string): Promise<Channel> {
|
||||||
|
return this.manager.rpc<Channel>('channels.get', { channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect a channel
|
||||||
|
*/
|
||||||
|
async connectChannel(channelId: string): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('channels.connect', { channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect a channel
|
||||||
|
*/
|
||||||
|
async disconnectChannel(channelId: string): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('channels.disconnect', { channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code for channel connection (e.g., WhatsApp)
|
||||||
|
*/
|
||||||
|
async getChannelQRCode(channelType: ChannelType): Promise<string> {
|
||||||
|
return this.manager.rpc<string>('channels.getQRCode', { channelType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Skill Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all skills
|
||||||
|
*/
|
||||||
|
async listSkills(): Promise<Skill[]> {
|
||||||
|
return this.manager.rpc<Skill[]>('skills.list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a skill
|
||||||
|
*/
|
||||||
|
async enableSkill(skillId: string): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('skills.enable', { skillId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a skill
|
||||||
|
*/
|
||||||
|
async disableSkill(skillId: string): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('skills.disable', { skillId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skill configuration
|
||||||
|
*/
|
||||||
|
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
|
||||||
|
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update skill configuration
|
||||||
|
*/
|
||||||
|
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chat Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat message
|
||||||
|
*/
|
||||||
|
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
|
||||||
|
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chat history
|
||||||
|
*/
|
||||||
|
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||||
|
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear chat history
|
||||||
|
*/
|
||||||
|
async clearChatHistory(): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('chat.clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== System Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Gateway health status
|
||||||
|
*/
|
||||||
|
async getHealth(): Promise<{ status: string; uptime: number }> {
|
||||||
|
return this.manager.rpc<{ status: string; uptime: number }>('system.health');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Gateway configuration
|
||||||
|
*/
|
||||||
|
async getConfig(): Promise<Record<string, unknown>> {
|
||||||
|
return this.manager.rpc<Record<string, unknown>>('system.config');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Gateway configuration
|
||||||
|
*/
|
||||||
|
async updateConfig(config: Record<string, unknown>): Promise<void> {
|
||||||
|
return this.manager.rpc<void>('system.updateConfig', config);
|
||||||
|
}
|
||||||
|
}
|
||||||
370
electron/gateway/manager.ts
Normal file
370
electron/gateway/manager.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Gateway Process Manager
|
||||||
|
* Manages the OpenClaw Gateway process lifecycle
|
||||||
|
*/
|
||||||
|
import { spawn, ChildProcess, exec } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { PORTS } from '../utils/config';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway connection status
|
||||||
|
*/
|
||||||
|
export interface GatewayStatus {
|
||||||
|
state: 'stopped' | 'starting' | 'running' | 'error';
|
||||||
|
port: number;
|
||||||
|
pid?: number;
|
||||||
|
uptime?: number;
|
||||||
|
error?: string;
|
||||||
|
connectedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway Manager Events
|
||||||
|
*/
|
||||||
|
export interface GatewayManagerEvents {
|
||||||
|
status: (status: GatewayStatus) => void;
|
||||||
|
message: (message: unknown) => void;
|
||||||
|
exit: (code: number | null) => void;
|
||||||
|
error: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway Manager
|
||||||
|
* Handles starting, stopping, and communicating with the OpenClaw Gateway
|
||||||
|
*/
|
||||||
|
export class GatewayManager extends EventEmitter {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
private pendingRequests: Map<string, {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current Gateway status
|
||||||
|
*/
|
||||||
|
getStatus(): GatewayStatus {
|
||||||
|
return { ...this.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Gateway process
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.status.state === 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatus({ state: 'starting' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if Gateway is already running
|
||||||
|
const existing = await this.findExistingGateway();
|
||||||
|
if (existing) {
|
||||||
|
console.log('Found existing Gateway on port', existing.port);
|
||||||
|
await this.connect(existing.port);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new Gateway process
|
||||||
|
await this.startProcess();
|
||||||
|
|
||||||
|
// Wait for Gateway to be ready
|
||||||
|
await this.waitForReady();
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
await this.connect(this.status.port);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.setStatus({ state: 'error', error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Gateway process
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
// Clear timers
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.pingInterval) {
|
||||||
|
clearInterval(this.pingInterval);
|
||||||
|
this.pingInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close WebSocket
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill process
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill();
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [id, request] of this.pendingRequests) {
|
||||||
|
clearTimeout(request.timeout);
|
||||||
|
request.reject(new Error('Gateway stopped'));
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
|
||||||
|
this.setStatus({ state: 'stopped' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an RPC call to the Gateway
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Set timeout for request
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(id);
|
||||||
|
reject(new Error(`RPC timeout: ${method}`));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Store pending request
|
||||||
|
this.pendingRequests.set(id, {
|
||||||
|
resolve: resolve as (value: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
const request = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.send(JSON.stringify(request));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing Gateway process
|
||||||
|
*/
|
||||||
|
private async findExistingGateway(): Promise<{ port: number } | null> {
|
||||||
|
try {
|
||||||
|
// Try to connect to default port
|
||||||
|
const port = PORTS.OPENCLAW_GATEWAY;
|
||||||
|
const response = await fetch(`http://localhost:${port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { port };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gateway not running
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Gateway process
|
||||||
|
*/
|
||||||
|
private async startProcess(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Find openclaw command
|
||||||
|
const command = 'openclaw';
|
||||||
|
const args = ['gateway', 'run', '--port', String(this.status.port)];
|
||||||
|
|
||||||
|
this.process = spawn(command, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', (error) => {
|
||||||
|
console.error('Gateway process error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('exit', (code) => {
|
||||||
|
console.log('Gateway process exited with code:', code);
|
||||||
|
this.emit('exit', code);
|
||||||
|
|
||||||
|
if (this.status.state === 'running') {
|
||||||
|
this.setStatus({ state: 'stopped' });
|
||||||
|
// Attempt to reconnect
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log stdout
|
||||||
|
this.process.stdout?.on('data', (data) => {
|
||||||
|
console.log('Gateway:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log stderr
|
||||||
|
this.process.stderr?.on('data', (data) => {
|
||||||
|
console.error('Gateway error:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store PID
|
||||||
|
if (this.process.pid) {
|
||||||
|
this.setStatus({ pid: this.process.pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for Gateway to be ready
|
||||||
|
*/
|
||||||
|
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${this.status.port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Gateway not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Gateway failed to start');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect WebSocket to Gateway
|
||||||
|
*/
|
||||||
|
private async connect(port: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const wsUrl = `ws://localhost:${port}/ws`;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
console.log('WebSocket connected to Gateway');
|
||||||
|
this.setStatus({
|
||||||
|
state: 'running',
|
||||||
|
port,
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
});
|
||||||
|
this.startPing();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
this.handleMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
if (this.status.state === 'running') {
|
||||||
|
this.setStatus({ state: 'stopped' });
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming WebSocket message
|
||||||
|
*/
|
||||||
|
private handleMessage(message: { id?: string; result?: unknown; error?: unknown }): void {
|
||||||
|
// Check if this is a response to a pending request
|
||||||
|
if (message.id && this.pendingRequests.has(message.id)) {
|
||||||
|
const request = this.pendingRequests.get(message.id)!;
|
||||||
|
clearTimeout(request.timeout);
|
||||||
|
this.pendingRequests.delete(message.id);
|
||||||
|
|
||||||
|
if (message.error) {
|
||||||
|
request.reject(new Error(String(message.error)));
|
||||||
|
} else {
|
||||||
|
request.resolve(message.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit message for other handlers
|
||||||
|
this.emit('message', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start ping interval to keep connection alive
|
||||||
|
*/
|
||||||
|
private startPing(): void {
|
||||||
|
if (this.pingInterval) {
|
||||||
|
clearInterval(this.pingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pingInterval = setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.ping();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule reconnection attempt
|
||||||
|
*/
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(async () => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
try {
|
||||||
|
await this.start();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reconnection failed:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status and emit event
|
||||||
|
*/
|
||||||
|
private setStatus(update: Partial<GatewayStatus>): void {
|
||||||
|
this.status = { ...this.status, ...update };
|
||||||
|
this.emit('status', this.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
electron/gateway/protocol.ts
Normal file
200
electron/gateway/protocol.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Gateway Protocol Definitions
|
||||||
|
* JSON-RPC 2.0 protocol types for Gateway communication
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 Request
|
||||||
|
*/
|
||||||
|
export interface JsonRpcRequest {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: string | number;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 Response
|
||||||
|
*/
|
||||||
|
export interface JsonRpcResponse<T = unknown> {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: string | number;
|
||||||
|
result?: T;
|
||||||
|
error?: JsonRpcError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 Error
|
||||||
|
*/
|
||||||
|
export interface JsonRpcError {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 Notification (no id, no response expected)
|
||||||
|
*/
|
||||||
|
export interface JsonRpcNotification {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard JSON-RPC 2.0 Error Codes
|
||||||
|
*/
|
||||||
|
export enum JsonRpcErrorCode {
|
||||||
|
/** Invalid JSON was received */
|
||||||
|
PARSE_ERROR = -32700,
|
||||||
|
/** The JSON sent is not a valid Request object */
|
||||||
|
INVALID_REQUEST = -32600,
|
||||||
|
/** The method does not exist or is not available */
|
||||||
|
METHOD_NOT_FOUND = -32601,
|
||||||
|
/** Invalid method parameter(s) */
|
||||||
|
INVALID_PARAMS = -32602,
|
||||||
|
/** Internal JSON-RPC error */
|
||||||
|
INTERNAL_ERROR = -32603,
|
||||||
|
/** Server error range: -32000 to -32099 */
|
||||||
|
SERVER_ERROR = -32000,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway-specific error codes
|
||||||
|
*/
|
||||||
|
export enum GatewayErrorCode {
|
||||||
|
/** Gateway not connected */
|
||||||
|
NOT_CONNECTED = -32001,
|
||||||
|
/** Authentication required */
|
||||||
|
AUTH_REQUIRED = -32002,
|
||||||
|
/** Permission denied */
|
||||||
|
PERMISSION_DENIED = -32003,
|
||||||
|
/** Resource not found */
|
||||||
|
NOT_FOUND = -32004,
|
||||||
|
/** Operation timeout */
|
||||||
|
TIMEOUT = -32005,
|
||||||
|
/** Rate limit exceeded */
|
||||||
|
RATE_LIMITED = -32006,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway event types
|
||||||
|
*/
|
||||||
|
export enum GatewayEventType {
|
||||||
|
/** Gateway status changed */
|
||||||
|
STATUS_CHANGED = 'gateway.status_changed',
|
||||||
|
/** Channel status changed */
|
||||||
|
CHANNEL_STATUS_CHANGED = 'channel.status_changed',
|
||||||
|
/** New chat message received */
|
||||||
|
MESSAGE_RECEIVED = 'chat.message_received',
|
||||||
|
/** Message sent */
|
||||||
|
MESSAGE_SENT = 'chat.message_sent',
|
||||||
|
/** Tool call started */
|
||||||
|
TOOL_CALL_STARTED = 'tool.call_started',
|
||||||
|
/** Tool call completed */
|
||||||
|
TOOL_CALL_COMPLETED = 'tool.call_completed',
|
||||||
|
/** Error occurred */
|
||||||
|
ERROR = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway event payload
|
||||||
|
*/
|
||||||
|
export interface GatewayEvent<T = unknown> {
|
||||||
|
type: GatewayEventType;
|
||||||
|
timestamp: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JSON-RPC request
|
||||||
|
*/
|
||||||
|
export function createRequest(
|
||||||
|
method: string,
|
||||||
|
params?: unknown,
|
||||||
|
id?: string | number
|
||||||
|
): JsonRpcRequest {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: id ?? crypto.randomUUID(),
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JSON-RPC success response
|
||||||
|
*/
|
||||||
|
export function createSuccessResponse<T>(
|
||||||
|
id: string | number,
|
||||||
|
result: T
|
||||||
|
): JsonRpcResponse<T> {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JSON-RPC error response
|
||||||
|
*/
|
||||||
|
export function createErrorResponse(
|
||||||
|
id: string | number,
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
data?: unknown
|
||||||
|
): JsonRpcResponse {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message is a JSON-RPC request
|
||||||
|
*/
|
||||||
|
export function isRequest(message: unknown): message is JsonRpcRequest {
|
||||||
|
return (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'jsonrpc' in message &&
|
||||||
|
message.jsonrpc === '2.0' &&
|
||||||
|
'method' in message &&
|
||||||
|
typeof message.method === 'string' &&
|
||||||
|
'id' in message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message is a JSON-RPC response
|
||||||
|
*/
|
||||||
|
export function isResponse(message: unknown): message is JsonRpcResponse {
|
||||||
|
return (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'jsonrpc' in message &&
|
||||||
|
message.jsonrpc === '2.0' &&
|
||||||
|
'id' in message &&
|
||||||
|
('result' in message || 'error' in message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message is a JSON-RPC notification
|
||||||
|
*/
|
||||||
|
export function isNotification(message: unknown): message is JsonRpcNotification {
|
||||||
|
return (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'jsonrpc' in message &&
|
||||||
|
message.jsonrpc === '2.0' &&
|
||||||
|
'method' in message &&
|
||||||
|
!('id' in message)
|
||||||
|
);
|
||||||
|
}
|
||||||
118
electron/main/index.ts
Normal file
118
electron/main/index.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Electron Main Process Entry
|
||||||
|
* Manages window creation, system tray, and IPC handlers
|
||||||
|
*/
|
||||||
|
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { GatewayManager } from '../gateway/manager';
|
||||||
|
import { registerIpcHandlers } from './ipc-handlers';
|
||||||
|
import { createTray } from './tray';
|
||||||
|
import { createMenu } from './menu';
|
||||||
|
import { PORTS } from '../utils/config';
|
||||||
|
|
||||||
|
// Disable GPU acceleration for better compatibility
|
||||||
|
app.disableHardwareAcceleration();
|
||||||
|
|
||||||
|
// Global references
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
const gatewayManager = new GatewayManager();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the main application window
|
||||||
|
*/
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 600,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||||
|
trafficLightPosition: { x: 16, y: 16 },
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show window when ready to prevent visual flash
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle external links
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the app
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||||
|
// Open DevTools in development
|
||||||
|
win.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the application
|
||||||
|
*/
|
||||||
|
async function initialize(): Promise<void> {
|
||||||
|
// Set application menu
|
||||||
|
createMenu();
|
||||||
|
|
||||||
|
// Create the main window
|
||||||
|
mainWindow = createWindow();
|
||||||
|
|
||||||
|
// Create system tray
|
||||||
|
createTray(mainWindow);
|
||||||
|
|
||||||
|
// Register IPC handlers
|
||||||
|
registerIpcHandlers(gatewayManager, mainWindow);
|
||||||
|
|
||||||
|
// Handle window close
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Gateway automatically (optional based on settings)
|
||||||
|
try {
|
||||||
|
await gatewayManager.start();
|
||||||
|
console.log('Gateway started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start Gateway:', error);
|
||||||
|
// Notify renderer about the error
|
||||||
|
mainWindow?.webContents.send('gateway:error', String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application lifecycle
|
||||||
|
app.whenReady().then(initialize);
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// On macOS, keep the app running in the menu bar
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
// On macOS, re-create window when dock icon is clicked
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
mainWindow = createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', async () => {
|
||||||
|
// Clean up Gateway process
|
||||||
|
await gatewayManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for testing
|
||||||
|
export { mainWindow, gatewayManager };
|
||||||
172
electron/main/ipc-handlers.ts
Normal file
172
electron/main/ipc-handlers.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* IPC Handlers
|
||||||
|
* Registers all IPC handlers for main-renderer communication
|
||||||
|
*/
|
||||||
|
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
||||||
|
import { GatewayManager } from '../gateway/manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all IPC handlers
|
||||||
|
*/
|
||||||
|
export function registerIpcHandlers(
|
||||||
|
gatewayManager: GatewayManager,
|
||||||
|
mainWindow: BrowserWindow
|
||||||
|
): void {
|
||||||
|
// Gateway handlers
|
||||||
|
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||||
|
|
||||||
|
// Shell handlers
|
||||||
|
registerShellHandlers();
|
||||||
|
|
||||||
|
// Dialog handlers
|
||||||
|
registerDialogHandlers();
|
||||||
|
|
||||||
|
// App handlers
|
||||||
|
registerAppHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway-related IPC handlers
|
||||||
|
*/
|
||||||
|
function registerGatewayHandlers(
|
||||||
|
gatewayManager: GatewayManager,
|
||||||
|
mainWindow: BrowserWindow
|
||||||
|
): void {
|
||||||
|
// Get Gateway status
|
||||||
|
ipcMain.handle('gateway:status', () => {
|
||||||
|
return gatewayManager.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start Gateway
|
||||||
|
ipcMain.handle('gateway:start', async () => {
|
||||||
|
try {
|
||||||
|
await gatewayManager.start();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop Gateway
|
||||||
|
ipcMain.handle('gateway:stop', async () => {
|
||||||
|
try {
|
||||||
|
await gatewayManager.stop();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart Gateway
|
||||||
|
ipcMain.handle('gateway:restart', async () => {
|
||||||
|
try {
|
||||||
|
await gatewayManager.stop();
|
||||||
|
await gatewayManager.start();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gateway RPC call
|
||||||
|
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown) => {
|
||||||
|
try {
|
||||||
|
const result = await gatewayManager.rpc(method, params);
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward Gateway status events to renderer
|
||||||
|
gatewayManager.on('status', (status) => {
|
||||||
|
mainWindow.webContents.send('gateway:status-changed', status);
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayManager.on('message', (message) => {
|
||||||
|
mainWindow.webContents.send('gateway:message', message);
|
||||||
|
});
|
||||||
|
|
||||||
|
gatewayManager.on('exit', (code) => {
|
||||||
|
mainWindow.webContents.send('gateway:exit', code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell-related IPC handlers
|
||||||
|
*/
|
||||||
|
function registerShellHandlers(): void {
|
||||||
|
// Open external URL
|
||||||
|
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||||
|
await shell.openExternal(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open path in file explorer
|
||||||
|
ipcMain.handle('shell:showItemInFolder', async (_, path: string) => {
|
||||||
|
shell.showItemInFolder(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open path
|
||||||
|
ipcMain.handle('shell:openPath', async (_, path: string) => {
|
||||||
|
return await shell.openPath(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog-related IPC handlers
|
||||||
|
*/
|
||||||
|
function registerDialogHandlers(): void {
|
||||||
|
// Show open dialog
|
||||||
|
ipcMain.handle('dialog:open', async (_, options: Electron.OpenDialogOptions) => {
|
||||||
|
const result = await dialog.showOpenDialog(options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show save dialog
|
||||||
|
ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => {
|
||||||
|
const result = await dialog.showSaveDialog(options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show message box
|
||||||
|
ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => {
|
||||||
|
const result = await dialog.showMessageBox(options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-related IPC handlers
|
||||||
|
*/
|
||||||
|
function registerAppHandlers(): void {
|
||||||
|
// Get app version
|
||||||
|
ipcMain.handle('app:version', () => {
|
||||||
|
return app.getVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get app name
|
||||||
|
ipcMain.handle('app:name', () => {
|
||||||
|
return app.getName();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get app path
|
||||||
|
ipcMain.handle('app:getPath', (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||||
|
return app.getPath(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get platform
|
||||||
|
ipcMain.handle('app:platform', () => {
|
||||||
|
return process.platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quit app
|
||||||
|
ipcMain.handle('app:quit', () => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Relaunch app
|
||||||
|
ipcMain.handle('app:relaunch', () => {
|
||||||
|
app.relaunch();
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
201
electron/main/menu.ts
Normal file
201
electron/main/menu.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Application Menu Configuration
|
||||||
|
* Creates the native application menu for macOS/Windows/Linux
|
||||||
|
*/
|
||||||
|
import { Menu, app, shell, BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create application menu
|
||||||
|
*/
|
||||||
|
export function createMenu(): void {
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
|
||||||
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
// App menu (macOS only)
|
||||||
|
...(isMac
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: app.name,
|
||||||
|
submenu: [
|
||||||
|
{ role: 'about' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{
|
||||||
|
label: 'Preferences...',
|
||||||
|
accelerator: 'Cmd+,',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/settings');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'services' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'hide' as const },
|
||||||
|
{ role: 'hideOthers' as const },
|
||||||
|
{ role: 'unhide' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'quit' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
|
// File menu
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'New Chat',
|
||||||
|
accelerator: 'CmdOrCtrl+N',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/chat');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
isMac ? { role: 'close' } : { role: 'quit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edit menu
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
...(isMac
|
||||||
|
? [
|
||||||
|
{ role: 'pasteAndMatchStyle' as const },
|
||||||
|
{ role: 'delete' as const },
|
||||||
|
{ role: 'selectAll' as const },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ role: 'delete' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'selectAll' as const },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'reload' },
|
||||||
|
{ role: 'forceReload' },
|
||||||
|
{ role: 'toggleDevTools' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom' },
|
||||||
|
{ role: 'zoomIn' },
|
||||||
|
{ role: 'zoomOut' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigate menu
|
||||||
|
{
|
||||||
|
label: 'Navigate',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
accelerator: 'CmdOrCtrl+1',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Chat',
|
||||||
|
accelerator: 'CmdOrCtrl+2',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/chat');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Channels',
|
||||||
|
accelerator: 'CmdOrCtrl+3',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/channels');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Skills',
|
||||||
|
accelerator: 'CmdOrCtrl+4',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/skills');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cron Tasks',
|
||||||
|
accelerator: 'CmdOrCtrl+5',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/cron');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||||
|
click: () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
win?.webContents.send('navigate', '/settings');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
{
|
||||||
|
label: 'Window',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'minimize' },
|
||||||
|
{ role: 'zoom' },
|
||||||
|
...(isMac
|
||||||
|
? [
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'front' as const },
|
||||||
|
{ type: 'separator' as const },
|
||||||
|
{ role: 'window' as const },
|
||||||
|
]
|
||||||
|
: [{ role: 'close' as const }]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Help menu
|
||||||
|
{
|
||||||
|
role: 'help',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Documentation',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal('https://docs.clawx.app');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Report Issue',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal('https://github.com/clawx/clawx/issues');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'OpenClaw Documentation',
|
||||||
|
click: async () => {
|
||||||
|
await shell.openExternal('https://docs.openclaw.ai');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
Menu.setApplicationMenu(menu);
|
||||||
|
}
|
||||||
145
electron/main/tray.ts
Normal file
145
electron/main/tray.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* System Tray Management
|
||||||
|
* Creates and manages the system tray icon and menu
|
||||||
|
*/
|
||||||
|
import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create system tray icon and menu
|
||||||
|
*/
|
||||||
|
export function createTray(mainWindow: BrowserWindow): Tray {
|
||||||
|
// Create tray icon
|
||||||
|
const iconPath = join(__dirname, '../../resources/icons/tray-icon.png');
|
||||||
|
|
||||||
|
// Create a template image for macOS (adds @2x support automatically)
|
||||||
|
let icon = nativeImage.createFromPath(iconPath);
|
||||||
|
|
||||||
|
// If icon doesn't exist, create a simple placeholder
|
||||||
|
if (icon.isEmpty()) {
|
||||||
|
// Create a simple 16x16 icon as placeholder
|
||||||
|
icon = nativeImage.createEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// On macOS, set as template image for proper dark/light mode support
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
icon.setTemplateImage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
tray = new Tray(icon);
|
||||||
|
|
||||||
|
// Set tooltip
|
||||||
|
tray.setToolTip('ClawX - AI Assistant');
|
||||||
|
|
||||||
|
// Create context menu
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Show ClawX',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gateway Status',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: ' Running',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Quick Actions',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Open Dashboard',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.webContents.send('navigate', '/');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Chat',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.webContents.send('navigate', '/chat');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open Settings',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.webContents.send('navigate', '/settings');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Check for Updates...',
|
||||||
|
click: () => {
|
||||||
|
mainWindow.webContents.send('update:check');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Quit ClawX',
|
||||||
|
click: () => {
|
||||||
|
app.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
tray.setContextMenu(contextMenu);
|
||||||
|
|
||||||
|
// Click to show window (Windows/Linux)
|
||||||
|
tray.on('click', () => {
|
||||||
|
if (mainWindow.isVisible()) {
|
||||||
|
mainWindow.hide();
|
||||||
|
} else {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double-click to show window (Windows)
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return tray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tray tooltip with Gateway status
|
||||||
|
*/
|
||||||
|
export function updateTrayStatus(status: string): void {
|
||||||
|
if (tray) {
|
||||||
|
tray.setToolTip(`ClawX - ${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy tray icon
|
||||||
|
*/
|
||||||
|
export function destroyTray(): void {
|
||||||
|
if (tray) {
|
||||||
|
tray.destroy();
|
||||||
|
tray = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
electron/main/window.ts
Normal file
84
electron/main/window.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Window Management Utilities
|
||||||
|
* Handles window state persistence and multi-window management
|
||||||
|
*/
|
||||||
|
import { BrowserWindow, screen } from 'electron';
|
||||||
|
import Store from 'electron-store';
|
||||||
|
|
||||||
|
interface WindowState {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store<{ windowState: WindowState }>({
|
||||||
|
name: 'window-state',
|
||||||
|
defaults: {
|
||||||
|
windowState: {
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
isMaximized: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get saved window state with bounds validation
|
||||||
|
*/
|
||||||
|
export function getWindowState(): WindowState {
|
||||||
|
const state = store.get('windowState');
|
||||||
|
|
||||||
|
// Validate that the window is visible on a screen
|
||||||
|
if (state.x !== undefined && state.y !== undefined) {
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
const isVisible = displays.some((display) => {
|
||||||
|
const { x, y, width, height } = display.bounds;
|
||||||
|
return (
|
||||||
|
state.x! >= x &&
|
||||||
|
state.x! < x + width &&
|
||||||
|
state.y! >= y &&
|
||||||
|
state.y! < y + height
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
// Reset position if not visible
|
||||||
|
delete state.x;
|
||||||
|
delete state.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save window state
|
||||||
|
*/
|
||||||
|
export function saveWindowState(win: BrowserWindow): void {
|
||||||
|
const isMaximized = win.isMaximized();
|
||||||
|
|
||||||
|
if (!isMaximized) {
|
||||||
|
const bounds = win.getBounds();
|
||||||
|
store.set('windowState', {
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
isMaximized,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store.set('windowState.isMaximized', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track window state changes
|
||||||
|
*/
|
||||||
|
export function trackWindowState(win: BrowserWindow): void {
|
||||||
|
// Save state on window events
|
||||||
|
['resize', 'move', 'close'].forEach((event) => {
|
||||||
|
win.on(event as any, () => saveWindowState(win));
|
||||||
|
});
|
||||||
|
}
|
||||||
159
electron/preload/index.ts
Normal file
159
electron/preload/index.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Preload Script
|
||||||
|
* Exposes safe APIs to the renderer process via contextBridge
|
||||||
|
*/
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC renderer methods exposed to the renderer process
|
||||||
|
*/
|
||||||
|
const electronAPI = {
|
||||||
|
/**
|
||||||
|
* IPC invoke (request-response pattern)
|
||||||
|
*/
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke: (channel: string, ...args: unknown[]) => {
|
||||||
|
const validChannels = [
|
||||||
|
// Gateway
|
||||||
|
'gateway:status',
|
||||||
|
'gateway:start',
|
||||||
|
'gateway:stop',
|
||||||
|
'gateway:restart',
|
||||||
|
'gateway:rpc',
|
||||||
|
// Shell
|
||||||
|
'shell:openExternal',
|
||||||
|
'shell:showItemInFolder',
|
||||||
|
'shell:openPath',
|
||||||
|
// Dialog
|
||||||
|
'dialog:open',
|
||||||
|
'dialog:save',
|
||||||
|
'dialog:message',
|
||||||
|
// App
|
||||||
|
'app:version',
|
||||||
|
'app:name',
|
||||||
|
'app:getPath',
|
||||||
|
'app:platform',
|
||||||
|
'app:quit',
|
||||||
|
'app:relaunch',
|
||||||
|
// Settings
|
||||||
|
'settings:get',
|
||||||
|
'settings:set',
|
||||||
|
'settings:getAll',
|
||||||
|
'settings:reset',
|
||||||
|
// Update
|
||||||
|
'update:check',
|
||||||
|
'update:download',
|
||||||
|
'update:install',
|
||||||
|
'update:getStatus',
|
||||||
|
// Env
|
||||||
|
'env:getConfig',
|
||||||
|
'env:setApiKey',
|
||||||
|
'env:deleteApiKey',
|
||||||
|
// Provider
|
||||||
|
'provider:validateKey',
|
||||||
|
// Cron
|
||||||
|
'cron:list',
|
||||||
|
'cron:create',
|
||||||
|
'cron:update',
|
||||||
|
'cron:delete',
|
||||||
|
'cron:toggle',
|
||||||
|
'cron:trigger',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (validChannels.includes(channel)) {
|
||||||
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for events from main process
|
||||||
|
*/
|
||||||
|
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||||
|
const validChannels = [
|
||||||
|
'gateway:status-changed',
|
||||||
|
'gateway:message',
|
||||||
|
'gateway:exit',
|
||||||
|
'gateway:error',
|
||||||
|
'navigate',
|
||||||
|
'update:available',
|
||||||
|
'update:downloaded',
|
||||||
|
'update:status',
|
||||||
|
'cron:updated',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (validChannels.includes(channel)) {
|
||||||
|
// Wrap the callback to strip the event
|
||||||
|
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||||
|
callback(...args);
|
||||||
|
};
|
||||||
|
ipcRenderer.on(channel, subscription);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(channel, subscription);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for a single event from main process
|
||||||
|
*/
|
||||||
|
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||||
|
const validChannels = [
|
||||||
|
'gateway:status-changed',
|
||||||
|
'gateway:message',
|
||||||
|
'gateway:exit',
|
||||||
|
'gateway:error',
|
||||||
|
'navigate',
|
||||||
|
'update:available',
|
||||||
|
'update:downloaded',
|
||||||
|
'update:status',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (validChannels.includes(channel)) {
|
||||||
|
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid IPC channel: ${channel}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all listeners for a channel
|
||||||
|
*/
|
||||||
|
off: (channel: string, callback?: (...args: unknown[]) => void) => {
|
||||||
|
if (callback) {
|
||||||
|
ipcRenderer.removeListener(channel, callback as any);
|
||||||
|
} else {
|
||||||
|
ipcRenderer.removeAllListeners(channel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open external URL in default browser
|
||||||
|
*/
|
||||||
|
openExternal: (url: string) => {
|
||||||
|
return ipcRenderer.invoke('shell:openExternal', url);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current platform
|
||||||
|
*/
|
||||||
|
platform: process.platform,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in development
|
||||||
|
*/
|
||||||
|
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose the API to the renderer process
|
||||||
|
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||||
|
|
||||||
|
// Type declarations for the renderer process
|
||||||
|
export type ElectronAPI = typeof electronAPI;
|
||||||
84
electron/utils/config.ts
Normal file
84
electron/utils/config.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Application Configuration
|
||||||
|
* Centralized configuration constants and helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port configuration
|
||||||
|
*/
|
||||||
|
export const PORTS = {
|
||||||
|
/** ClawX GUI development server port */
|
||||||
|
CLAWX_DEV: 5173,
|
||||||
|
|
||||||
|
/** ClawX GUI production port (for reference) */
|
||||||
|
CLAWX_GUI: 23333,
|
||||||
|
|
||||||
|
/** OpenClaw Gateway port */
|
||||||
|
OPENCLAW_GATEWAY: 18789,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get port from environment or default
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application paths
|
||||||
|
*/
|
||||||
|
export const APP_PATHS = {
|
||||||
|
/** OpenClaw configuration directory */
|
||||||
|
OPENCLAW_CONFIG: '~/.openclaw',
|
||||||
|
|
||||||
|
/** ClawX configuration directory */
|
||||||
|
CLAWX_CONFIG: '~/.clawx',
|
||||||
|
|
||||||
|
/** Log files directory */
|
||||||
|
LOGS: '~/.clawx/logs',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update channels
|
||||||
|
*/
|
||||||
|
export const UPDATE_CHANNELS = ['stable', 'beta', 'dev'] as const;
|
||||||
|
export type UpdateChannel = (typeof UPDATE_CHANNELS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default update configuration
|
||||||
|
*/
|
||||||
|
export const UPDATE_CONFIG = {
|
||||||
|
/** Check interval in milliseconds (6 hours) */
|
||||||
|
CHECK_INTERVAL: 6 * 60 * 60 * 1000,
|
||||||
|
|
||||||
|
/** Default update channel */
|
||||||
|
DEFAULT_CHANNEL: 'stable' as UpdateChannel,
|
||||||
|
|
||||||
|
/** Auto download updates */
|
||||||
|
AUTO_DOWNLOAD: false,
|
||||||
|
|
||||||
|
/** Show update notifications */
|
||||||
|
SHOW_NOTIFICATION: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway configuration
|
||||||
|
*/
|
||||||
|
export const GATEWAY_CONFIG = {
|
||||||
|
/** WebSocket reconnection delay (ms) */
|
||||||
|
RECONNECT_DELAY: 5000,
|
||||||
|
|
||||||
|
/** RPC call timeout (ms) */
|
||||||
|
RPC_TIMEOUT: 30000,
|
||||||
|
|
||||||
|
/** Health check interval (ms) */
|
||||||
|
HEALTH_CHECK_INTERVAL: 30000,
|
||||||
|
|
||||||
|
/** Maximum startup retries */
|
||||||
|
MAX_STARTUP_RETRIES: 30,
|
||||||
|
|
||||||
|
/** Startup retry interval (ms) */
|
||||||
|
STARTUP_RETRY_INTERVAL: 1000,
|
||||||
|
};
|
||||||
133
electron/utils/logger.ts
Normal file
133
electron/utils/logger.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Logger Utility
|
||||||
|
* Centralized logging with levels and file output
|
||||||
|
*/
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log levels
|
||||||
|
*/
|
||||||
|
export enum LogLevel {
|
||||||
|
DEBUG = 0,
|
||||||
|
INFO = 1,
|
||||||
|
WARN = 2,
|
||||||
|
ERROR = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current log level (can be changed at runtime)
|
||||||
|
*/
|
||||||
|
let currentLevel = LogLevel.INFO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log file path
|
||||||
|
*/
|
||||||
|
let logFilePath: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize logger
|
||||||
|
*/
|
||||||
|
export function initLogger(): void {
|
||||||
|
try {
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs');
|
||||||
|
|
||||||
|
if (!existsSync(logDir)) {
|
||||||
|
mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize logger:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set log level
|
||||||
|
*/
|
||||||
|
export function setLogLevel(level: LogLevel): void {
|
||||||
|
currentLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format log message
|
||||||
|
*/
|
||||||
|
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const formattedArgs = args.length > 0 ? ' ' + args.map(arg =>
|
||||||
|
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||||
|
).join(' ') : '';
|
||||||
|
|
||||||
|
return `[${timestamp}] [${level}] ${message}${formattedArgs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write to log file
|
||||||
|
*/
|
||||||
|
function writeToFile(formatted: string): void {
|
||||||
|
if (logFilePath) {
|
||||||
|
try {
|
||||||
|
appendFileSync(logFilePath, formatted + '\n');
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail if we can't write to file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log debug message
|
||||||
|
*/
|
||||||
|
export function debug(message: string, ...args: unknown[]): void {
|
||||||
|
if (currentLevel <= LogLevel.DEBUG) {
|
||||||
|
const formatted = formatMessage('DEBUG', message, ...args);
|
||||||
|
console.debug(formatted);
|
||||||
|
writeToFile(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log info message
|
||||||
|
*/
|
||||||
|
export function info(message: string, ...args: unknown[]): void {
|
||||||
|
if (currentLevel <= LogLevel.INFO) {
|
||||||
|
const formatted = formatMessage('INFO', message, ...args);
|
||||||
|
console.info(formatted);
|
||||||
|
writeToFile(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log warning message
|
||||||
|
*/
|
||||||
|
export function warn(message: string, ...args: unknown[]): void {
|
||||||
|
if (currentLevel <= LogLevel.WARN) {
|
||||||
|
const formatted = formatMessage('WARN', message, ...args);
|
||||||
|
console.warn(formatted);
|
||||||
|
writeToFile(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error message
|
||||||
|
*/
|
||||||
|
export function error(message: string, ...args: unknown[]): void {
|
||||||
|
if (currentLevel <= LogLevel.ERROR) {
|
||||||
|
const formatted = formatMessage('ERROR', message, ...args);
|
||||||
|
console.error(formatted);
|
||||||
|
writeToFile(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger namespace export
|
||||||
|
*/
|
||||||
|
export const logger = {
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warn,
|
||||||
|
error,
|
||||||
|
setLevel: setLogLevel,
|
||||||
|
init: initLogger,
|
||||||
|
};
|
||||||
72
electron/utils/paths.ts
Normal file
72
electron/utils/paths.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Path Utilities
|
||||||
|
* Cross-platform path resolution helpers
|
||||||
|
*/
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand ~ to home directory
|
||||||
|
*/
|
||||||
|
export function expandPath(path: string): string {
|
||||||
|
if (path.startsWith('~')) {
|
||||||
|
return path.replace('~', homedir());
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OpenClaw config directory
|
||||||
|
*/
|
||||||
|
export function getOpenClawConfigDir(): string {
|
||||||
|
return join(homedir(), '.openclaw');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ClawX config directory
|
||||||
|
*/
|
||||||
|
export function getClawXConfigDir(): string {
|
||||||
|
return join(homedir(), '.clawx');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ClawX logs directory
|
||||||
|
*/
|
||||||
|
export function getLogsDir(): string {
|
||||||
|
return join(app.getPath('userData'), 'logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ClawX data directory
|
||||||
|
*/
|
||||||
|
export function getDataDir(): string {
|
||||||
|
return app.getPath('userData');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure directory exists
|
||||||
|
*/
|
||||||
|
export function ensureDir(dir: string): void {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resources directory (for bundled assets)
|
||||||
|
*/
|
||||||
|
export function getResourcesDir(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return join(process.resourcesPath, 'resources');
|
||||||
|
}
|
||||||
|
return join(__dirname, '../../resources');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get preload script path
|
||||||
|
*/
|
||||||
|
export function getPreloadPath(): string {
|
||||||
|
return join(__dirname, '../preload/index.js');
|
||||||
|
}
|
||||||
123
electron/utils/store.ts
Normal file
123
electron/utils/store.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Persistent Storage
|
||||||
|
* Electron-store wrapper for application settings
|
||||||
|
*/
|
||||||
|
import Store from 'electron-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application settings schema
|
||||||
|
*/
|
||||||
|
export interface AppSettings {
|
||||||
|
// General
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
language: string;
|
||||||
|
startMinimized: boolean;
|
||||||
|
launchAtStartup: boolean;
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
gatewayAutoStart: boolean;
|
||||||
|
gatewayPort: number;
|
||||||
|
|
||||||
|
// Update
|
||||||
|
updateChannel: 'stable' | 'beta' | 'dev';
|
||||||
|
autoCheckUpdate: boolean;
|
||||||
|
autoDownloadUpdate: boolean;
|
||||||
|
skippedVersions: string[];
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
devModeUnlocked: boolean;
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
selectedBundles: string[];
|
||||||
|
enabledSkills: string[];
|
||||||
|
disabledSkills: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default settings
|
||||||
|
*/
|
||||||
|
const defaults: AppSettings = {
|
||||||
|
// General
|
||||||
|
theme: 'system',
|
||||||
|
language: 'en',
|
||||||
|
startMinimized: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
gatewayAutoStart: true,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
|
||||||
|
// Update
|
||||||
|
updateChannel: 'stable',
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
autoDownloadUpdate: false,
|
||||||
|
skippedVersions: [],
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
devModeUnlocked: false,
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
selectedBundles: ['productivity', 'developer'],
|
||||||
|
enabledSkills: [],
|
||||||
|
disabledSkills: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create settings store
|
||||||
|
*/
|
||||||
|
export const settingsStore = new Store<AppSettings>({
|
||||||
|
name: 'settings',
|
||||||
|
defaults,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting value
|
||||||
|
*/
|
||||||
|
export function getSetting<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||||
|
return settingsStore.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a setting value
|
||||||
|
*/
|
||||||
|
export function setSetting<K extends keyof AppSettings>(
|
||||||
|
key: K,
|
||||||
|
value: AppSettings[K]
|
||||||
|
): void {
|
||||||
|
settingsStore.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all settings
|
||||||
|
*/
|
||||||
|
export function getAllSettings(): AppSettings {
|
||||||
|
return settingsStore.store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset settings to defaults
|
||||||
|
*/
|
||||||
|
export function resetSettings(): void {
|
||||||
|
settingsStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export settings to JSON
|
||||||
|
*/
|
||||||
|
export function exportSettings(): string {
|
||||||
|
return JSON.stringify(settingsStore.store, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import settings from JSON
|
||||||
|
*/
|
||||||
|
export function importSettings(json: string): void {
|
||||||
|
try {
|
||||||
|
const settings = JSON.parse(json);
|
||||||
|
settingsStore.set(settings);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Invalid settings JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ClawX</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
85
package.json
Normal file
85
package.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"name": "clawx",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["electron", "esbuild"]
|
||||||
|
},
|
||||||
|
"description": "ClawX - Graphical AI Assistant based on OpenClaw",
|
||||||
|
"main": "dist-electron/main/index.js",
|
||||||
|
"author": "ClawX Team",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:electron": "electron .",
|
||||||
|
"build": "tsc && vite build && electron-builder",
|
||||||
|
"build:vite": "vite build",
|
||||||
|
"build:electron": "tsc -p tsconfig.node.json",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"package": "electron-builder",
|
||||||
|
"package:mac": "electron-builder --mac",
|
||||||
|
"package:win": "electron-builder --win",
|
||||||
|
"package:linux": "electron-builder --linux",
|
||||||
|
"package:all": "electron-builder -mwl"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-store": "^10.0.0",
|
||||||
|
"electron-updater": "^6.3.9",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-progress": "^1.1.1",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
||||||
|
"@typescript-eslint/parser": "^8.19.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"electron": "^33.3.0",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"sonner": "^1.7.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.6",
|
||||||
|
"vite-plugin-electron": "^0.29.0",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7528
pnpm-lock.yaml
generated
Normal file
7528
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- electron
|
||||||
|
- esbuild
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
6
resources/icons/.gitkeep
Normal file
6
resources/icons/.gitkeep
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Placeholder for icon files
|
||||||
|
# Add the following icons:
|
||||||
|
# - icon.icns (macOS)
|
||||||
|
# - icon.ico (Windows)
|
||||||
|
# - icon.png (Linux)
|
||||||
|
# - tray-icon.png (System tray)
|
||||||
101
resources/skills/bundles.json
Normal file
101
resources/skills/bundles.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"web-browse",
|
||||||
|
"summarize"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
86
src/App.tsx
Normal file
86
src/App.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Root Application Component
|
||||||
|
* Handles routing and global providers
|
||||||
|
*/
|
||||||
|
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { MainLayout } from './components/layout/MainLayout';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Chat } from './pages/Chat';
|
||||||
|
import { Channels } from './pages/Channels';
|
||||||
|
import { Skills } from './pages/Skills';
|
||||||
|
import { Cron } from './pages/Cron';
|
||||||
|
import { Settings } from './pages/Settings';
|
||||||
|
import { Setup } from './pages/Setup';
|
||||||
|
import { useSettingsStore } from './stores/settings';
|
||||||
|
import { useGatewayStore } from './stores/gateway';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
|
const initGateway = useGatewayStore((state) => state.init);
|
||||||
|
|
||||||
|
// Initialize Gateway connection on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initGateway();
|
||||||
|
}, [initGateway]);
|
||||||
|
|
||||||
|
// Listen for navigation events from main process
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof unsubscribe === 'function') {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Apply theme
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
} else {
|
||||||
|
root.classList.add(theme);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
{/* Setup wizard (shown on first launch) */}
|
||||||
|
<Route path="/setup/*" element={<Setup />} />
|
||||||
|
|
||||||
|
{/* Main application routes */}
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/chat" element={<Chat />} />
|
||||||
|
<Route path="/channels" element={<Channels />} />
|
||||||
|
<Route path="/skills" element={<Skills />} />
|
||||||
|
<Route path="/cron" element={<Cron />} />
|
||||||
|
<Route path="/settings/*" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
{/* Global toast notifications */}
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
74
src/components/common/ErrorBoundary.tsx
Normal file
74
src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Error Boundary Component
|
||||||
|
* Catches and displays errors in the component tree
|
||||||
|
*/
|
||||||
|
import { Component, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
|
<CardTitle>Something went wrong</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
An unexpected error occurred. Please try again.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{this.state.error && (
|
||||||
|
<pre className="rounded-lg bg-muted p-4 text-sm overflow-auto max-h-40">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<Button onClick={this.handleReset} className="w-full">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/components/common/LoadingSpinner.tsx
Normal file
36
src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Loading Spinner Component
|
||||||
|
* Displays a spinning loader animation
|
||||||
|
*/
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center', className)}>
|
||||||
|
<Loader2 className={cn('animate-spin text-primary', sizeClasses[size])} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full page loading spinner
|
||||||
|
*/
|
||||||
|
export function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/common/StatusBadge.tsx
Normal file
46
src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Status Badge Component
|
||||||
|
* Displays connection/state status with color coding
|
||||||
|
*/
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: Status;
|
||||||
|
label?: string;
|
||||||
|
showDot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<Status, { label: string; variant: 'success' | 'secondary' | 'warning' | 'destructive' }> = {
|
||||||
|
connected: { label: 'Connected', variant: 'success' },
|
||||||
|
running: { label: 'Running', variant: 'success' },
|
||||||
|
disconnected: { label: 'Disconnected', variant: 'secondary' },
|
||||||
|
stopped: { label: 'Stopped', variant: 'secondary' },
|
||||||
|
connecting: { label: 'Connecting', variant: 'warning' },
|
||||||
|
starting: { label: 'Starting', variant: 'warning' },
|
||||||
|
error: { label: 'Error', variant: 'destructive' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label, showDot = true }: StatusBadgeProps) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const displayLabel = label || config.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={config.variant} className="gap-1.5">
|
||||||
|
{showDot && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 rounded-full',
|
||||||
|
config.variant === 'success' && 'bg-green-600',
|
||||||
|
config.variant === 'secondary' && 'bg-gray-400',
|
||||||
|
config.variant === 'warning' && 'bg-yellow-600 animate-pulse',
|
||||||
|
config.variant === 'destructive' && 'bg-red-600'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/layout/Header.tsx
Normal file
75
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Header Component
|
||||||
|
* Top navigation bar with search and actions
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Search, Bell, Moon, Sun, Monitor } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
|
||||||
|
// Page titles mapping
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
'/': 'Dashboard',
|
||||||
|
'/chat': 'Chat',
|
||||||
|
'/channels': 'Channels',
|
||||||
|
'/skills': 'Skills',
|
||||||
|
'/cron': 'Cron Tasks',
|
||||||
|
'/settings': 'Settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
|
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Get current page title
|
||||||
|
const currentTitle = pageTitles[location.pathname] || 'ClawX';
|
||||||
|
|
||||||
|
// Cycle through themes
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||||
|
const currentIndex = themes.indexOf(theme);
|
||||||
|
const nextIndex = (currentIndex + 1) % themes.length;
|
||||||
|
setTheme(themes[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get theme icon
|
||||||
|
const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
|
||||||
|
{/* Page Title */}
|
||||||
|
<h2 className="text-lg font-semibold">{currentTitle}</h2>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
className="w-64 pl-9"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<Button variant="ghost" size="icon" onClick={cycleTheme}>
|
||||||
|
<ThemeIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/layout/MainLayout.tsx
Normal file
36
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Main Layout Component
|
||||||
|
* Provides the primary app layout with sidebar and content area
|
||||||
|
*/
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col overflow-hidden transition-all duration-300',
|
||||||
|
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/layout/Sidebar.tsx
Normal file
206
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar Component
|
||||||
|
* Navigation sidebar with menu items
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
MessageSquare,
|
||||||
|
Radio,
|
||||||
|
Puzzle,
|
||||||
|
Clock,
|
||||||
|
Settings,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Terminal,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface NavItemProps {
|
||||||
|
to: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
badge?: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
{badge && (
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||||
|
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||||
|
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||||
|
const setDevModeUnlocked = useSettingsStore((state) => state.setDevModeUnlocked);
|
||||||
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
|
||||||
|
const [versionClicks, setVersionClicks] = useState(0);
|
||||||
|
const [appVersion, setAppVersion] = useState('0.1.0');
|
||||||
|
|
||||||
|
// Get app version
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron.ipcRenderer.invoke('app:version').then((version) => {
|
||||||
|
setAppVersion(version as string);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle version click for dev mode unlock
|
||||||
|
const handleVersionClick = () => {
|
||||||
|
const clicks = versionClicks + 1;
|
||||||
|
setVersionClicks(clicks);
|
||||||
|
|
||||||
|
if (clicks >= 5) {
|
||||||
|
if (!devModeUnlocked) {
|
||||||
|
setDevModeUnlocked(true);
|
||||||
|
toast.success('Developer mode unlocked!');
|
||||||
|
}
|
||||||
|
setVersionClicks(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset after 2 seconds of inactivity
|
||||||
|
setTimeout(() => setVersionClicks(0), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open developer console
|
||||||
|
const openDevConsole = () => {
|
||||||
|
window.electron.openExternal('http://localhost:18789');
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
||||||
|
{ to: '/chat', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
||||||
|
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: 'Channels' },
|
||||||
|
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: 'Skills' },
|
||||||
|
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
||||||
|
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r bg-background transition-all duration-300',
|
||||||
|
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header with drag region for macOS */}
|
||||||
|
<div className="drag-region flex h-14 items-center border-b px-4">
|
||||||
|
{/* macOS traffic light spacing */}
|
||||||
|
<div className="w-16" />
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<h1 className="no-drag text-xl font-bold">ClawX</h1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 p-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavItem
|
||||||
|
key={item.to}
|
||||||
|
{...item}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t p-2 space-y-2">
|
||||||
|
{/* Gateway Status */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2',
|
||||||
|
sidebarCollapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-2 w-2 rounded-full',
|
||||||
|
gatewayStatus.state === 'running' && 'bg-green-500',
|
||||||
|
gatewayStatus.state === 'starting' && 'bg-yellow-500 animate-pulse',
|
||||||
|
gatewayStatus.state === 'stopped' && 'bg-gray-400',
|
||||||
|
gatewayStatus.state === 'error' && 'bg-red-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Gateway: {gatewayStatus.state}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Developer Mode Button */}
|
||||||
|
{devModeUnlocked && !sidebarCollapsed && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={openDevConsole}
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
|
Developer Console
|
||||||
|
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<button
|
||||||
|
onClick={handleVersionClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-xs text-muted-foreground hover:text-foreground transition-colors',
|
||||||
|
sidebarCollapsed ? 'text-center' : 'px-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? `v${appVersion.split('.')[0]}` : `ClawX v${appVersion}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/ui/badge.tsx
Normal file
43
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Badge Component
|
||||||
|
* Based on shadcn/ui badge
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Button Component
|
||||||
|
* Based on shadcn/ui button
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
82
src/components/ui/card.tsx
Normal file
82
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Card Component
|
||||||
|
* Based on shadcn/ui card
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
28
src/components/ui/input.tsx
Normal file
28
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Input Component
|
||||||
|
* Based on shadcn/ui input
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
27
src/components/ui/label.tsx
Normal file
27
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Label Component
|
||||||
|
* Based on shadcn/ui label
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
32
src/components/ui/separator.tsx
Normal file
32
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Separator Component
|
||||||
|
* Based on shadcn/ui separator
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
30
src/components/ui/switch.tsx
Normal file
30
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Switch Component
|
||||||
|
* Based on shadcn/ui switch
|
||||||
|
*/
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
72
src/lib/utils.ts
Normal file
72
src/lib/utils.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Utility Functions
|
||||||
|
* Common utility functions for the application
|
||||||
|
*/
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge class names with Tailwind CSS classes
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time (e.g., "2 minutes ago")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: string | Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const then = new Date(date);
|
||||||
|
const diffMs = now.getTime() - then.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (diffMin < 60) {
|
||||||
|
return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (diffHour < 24) {
|
||||||
|
return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (diffDay < 7) {
|
||||||
|
return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
|
||||||
|
} else {
|
||||||
|
return then.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in seconds to human-readable string
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay for a specified number of milliseconds
|
||||||
|
*/
|
||||||
|
export function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis
|
||||||
|
*/
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.slice(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
16
src/main.tsx
Normal file
16
src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* React Application Entry Point
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
164
src/pages/Channels/index.tsx
Normal file
164
src/pages/Channels/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Channels Page
|
||||||
|
* Manage messaging channel connections
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Plus, Radio, RefreshCw, Settings } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useChannelsStore } from '@/stores/channels';
|
||||||
|
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||||
|
|
||||||
|
export function Channels() {
|
||||||
|
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel } = useChannelsStore();
|
||||||
|
|
||||||
|
// Fetch channels on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChannels();
|
||||||
|
}, [fetchChannels]);
|
||||||
|
|
||||||
|
// Supported channel types for adding
|
||||||
|
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Channels</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Connect and manage your messaging channels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={fetchChannels}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="py-4 text-destructive">
|
||||||
|
{error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Channels Grid */}
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Radio className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No channels configured</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Connect a messaging channel to start using ClawX
|
||||||
|
</p>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Your First Channel
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<Card key={channel.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-3xl">
|
||||||
|
{CHANNEL_ICONS[channel.type]}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{channel.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{CHANNEL_NAMES[channel.type]}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={channel.status} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{channel.lastActivity && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Last activity: {new Date(channel.lastActivity).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{channel.error && (
|
||||||
|
<p className="text-sm text-destructive mb-4">{channel.error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{channel.status === 'connected' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => disconnectChannel(channel.id)}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => connectChannel(channel.id)}
|
||||||
|
disabled={channel.status === 'connecting'}
|
||||||
|
>
|
||||||
|
{channel.status === 'connecting' ? 'Connecting...' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Channel Types */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Supported Channels</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Click on a channel type to add it
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{supportedTypes.map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto flex-col gap-2 py-4"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
|
||||||
|
<span>{CHANNEL_NAMES[type]}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Channels;
|
||||||
166
src/pages/Chat/index.tsx
Normal file
166
src/pages/Chat/index.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Chat Page
|
||||||
|
* Conversation interface with AI
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Send, Trash2, Bot, User } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Chat() {
|
||||||
|
const { messages, loading, sending, fetchHistory, sendMessage, clearHistory } = useChatStore();
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fetch history on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
}, [fetchHistory]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Handle send message
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || sending) return;
|
||||||
|
|
||||||
|
const content = input.trim();
|
||||||
|
setInput('');
|
||||||
|
await sendMessage(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle key press
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<Bot className="h-16 w-16 mb-4 opacity-50" />
|
||||||
|
<h3 className="text-lg font-medium">No messages yet</h3>
|
||||||
|
<p className="text-sm">Start a conversation with your AI assistant</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
'flex gap-3',
|
||||||
|
message.role === 'user' ? 'flex-row-reverse' : 'flex-row'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.role === 'user' ? (
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] rounded-lg px-4 py-2',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-xs',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'text-primary-foreground/70'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(message.timestamp)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tool Calls */}
|
||||||
|
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{message.toolCalls.map((tool) => (
|
||||||
|
<Card key={tool.id} className="bg-background/50">
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
Tool: {tool.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Status: {tool.status}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="border-t p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={clearHistory}
|
||||||
|
disabled={messages.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
disabled={sending}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSend} disabled={!input.trim() || sending}>
|
||||||
|
{sending ? (
|
||||||
|
<LoadingSpinner size="sm" className="text-primary-foreground" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Chat;
|
||||||
215
src/pages/Cron/index.tsx
Normal file
215
src/pages/Cron/index.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Cron Page
|
||||||
|
* Manage scheduled tasks
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useCronStore } from '@/stores/cron';
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
|
import type { CronJob } from '@/types/cron';
|
||||||
|
|
||||||
|
export function Cron() {
|
||||||
|
const { jobs, loading, error, fetchJobs, toggleJob, deleteJob, triggerJob } = useCronStore();
|
||||||
|
|
||||||
|
// Fetch jobs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs();
|
||||||
|
}, [fetchJobs]);
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const activeJobs = jobs.filter((j) => j.enabled);
|
||||||
|
const pausedJobs = jobs.filter((j) => !j.enabled);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Cron Tasks</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Schedule automated AI tasks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={fetchJobs}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-primary/10 p-3">
|
||||||
|
<Clock className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{jobs.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Tasks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
|
||||||
|
<Play className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{activeJobs.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Running</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-full bg-yellow-100 p-3 dark:bg-yellow-900">
|
||||||
|
<Pause className="h-6 w-6 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{pausedJobs.length}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Paused</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="py-4 text-destructive">
|
||||||
|
{error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jobs List */}
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No scheduled tasks</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
Create your first scheduled task to automate AI workflows
|
||||||
|
</p>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<CronJobCard
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
onToggle={(enabled) => toggleJob(job.id, enabled)}
|
||||||
|
onDelete={() => deleteJob(job.id)}
|
||||||
|
onTrigger={() => triggerJob(job.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CronJobCardProps {
|
||||||
|
job: CronJob;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onTrigger: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CronJobCard({ job, onToggle, onDelete, onTrigger }: CronJobCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">📋</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{job.name}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{job.schedule}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={job.enabled ? 'success' : 'secondary'}>
|
||||||
|
{job.enabled ? 'Active' : 'Paused'}
|
||||||
|
</Badge>
|
||||||
|
<Switch
|
||||||
|
checked={job.enabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">
|
||||||
|
{job.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
Target: {job.target.channelName}
|
||||||
|
</span>
|
||||||
|
{job.lastRun && (
|
||||||
|
<span>
|
||||||
|
Last run: {formatRelativeTime(job.lastRun.time)}
|
||||||
|
{job.lastRun.success ? ' ✓' : ' ✗'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job.nextRun && (
|
||||||
|
<span>
|
||||||
|
Next: {new Date(job.nextRun).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onTrigger}>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onDelete}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Cron;
|
||||||
251
src/pages/Dashboard/index.tsx
Normal file
251
src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Page
|
||||||
|
* Main overview page showing system status and quick actions
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
MessageSquare,
|
||||||
|
Radio,
|
||||||
|
Puzzle,
|
||||||
|
Clock,
|
||||||
|
Settings,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
import { useChannelsStore } from '@/stores/channels';
|
||||||
|
import { useSkillsStore } from '@/stores/skills';
|
||||||
|
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||||
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||||
|
const { channels, fetchChannels } = useChannelsStore();
|
||||||
|
const { skills, fetchSkills } = useSkillsStore();
|
||||||
|
|
||||||
|
// Fetch data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChannels();
|
||||||
|
fetchSkills();
|
||||||
|
}, [fetchChannels, fetchSkills]);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const connectedChannels = channels.filter((c) => c.status === 'connected').length;
|
||||||
|
const enabledSkills = skills.filter((s) => s.enabled).length;
|
||||||
|
|
||||||
|
// Calculate uptime
|
||||||
|
const uptime = gatewayStatus.connectedAt
|
||||||
|
? Math.floor((Date.now() - gatewayStatus.connectedAt) / 1000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Gateway Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Gateway</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={gatewayStatus.state} />
|
||||||
|
</div>
|
||||||
|
{gatewayStatus.state === 'running' && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Port: {gatewayStatus.port} | PID: {gatewayStatus.pid || 'N/A'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Channels */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Channels</CardTitle>
|
||||||
|
<Radio className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{connectedChannels}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{connectedChannels} of {channels.length} connected
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Skills</CardTitle>
|
||||||
|
<Puzzle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{enabledSkills}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{enabledSkills} of {skills.length} enabled
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{uptime > 0 ? formatUptime(uptime) : '—'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{gatewayStatus.state === 'running' ? 'Since last restart' : 'Gateway not running'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
<CardDescription>Common tasks and shortcuts</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||||
|
<Link to="/channels">
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>Add Channel</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||||
|
<Link to="/skills">
|
||||||
|
<Puzzle className="h-5 w-5" />
|
||||||
|
<span>Browse Skills</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||||
|
<Link to="/chat">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
<span>Open Chat</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||||
|
<Link to="/settings">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* Connected Channels */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Connected Channels</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No channels configured</p>
|
||||||
|
<Button variant="link" asChild className="mt-2">
|
||||||
|
<Link to="/channels">Add your first channel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.slice(0, 5).map((channel) => (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">
|
||||||
|
{channel.type === 'whatsapp' && '📱'}
|
||||||
|
{channel.type === 'telegram' && '✈️'}
|
||||||
|
{channel.type === 'discord' && '🎮'}
|
||||||
|
{channel.type === 'slack' && '💼'}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{channel.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{channel.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={channel.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Enabled Skills */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Active Skills</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{skills.filter((s) => s.enabled).length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No skills enabled</p>
|
||||||
|
<Button variant="link" asChild className="mt-2">
|
||||||
|
<Link to="/skills">Enable some skills</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((skill) => (
|
||||||
|
<Badge key={skill.id} variant="secondary">
|
||||||
|
{skill.icon && <span className="mr-1">{skill.icon}</span>}
|
||||||
|
{skill.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{skills.filter((s) => s.enabled).length > 12 && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
+{skills.filter((s) => s.enabled).length - 12} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format uptime in human-readable format
|
||||||
|
*/
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
282
src/pages/Settings/index.tsx
Normal file
282
src/pages/Settings/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Settings Page
|
||||||
|
* Application configuration
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
gatewayAutoStart,
|
||||||
|
setGatewayAutoStart,
|
||||||
|
autoCheckUpdate,
|
||||||
|
setAutoCheckUpdate,
|
||||||
|
autoDownloadUpdate,
|
||||||
|
setAutoDownloadUpdate,
|
||||||
|
devModeUnlocked,
|
||||||
|
} = useSettingsStore();
|
||||||
|
|
||||||
|
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
|
||||||
|
|
||||||
|
const [appVersion, setAppVersion] = useState('0.1.0');
|
||||||
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
|
|
||||||
|
// Get app version
|
||||||
|
useEffect(() => {
|
||||||
|
window.electron.ipcRenderer.invoke('app:version').then((version) => {
|
||||||
|
setAppVersion(version as string);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
const handleCheckUpdate = async () => {
|
||||||
|
setCheckingUpdate(true);
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('update:check');
|
||||||
|
} finally {
|
||||||
|
setCheckingUpdate(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open developer console
|
||||||
|
const openDevConsole = () => {
|
||||||
|
window.electron.openExternal('http://localhost:18789');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure your ClawX experience
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
<CardDescription>Customize the look and feel</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Theme</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={theme === 'light' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
>
|
||||||
|
<Sun className="h-4 w-4 mr-2" />
|
||||||
|
Light
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
>
|
||||||
|
<Moon className="h-4 w-4 mr-2" />
|
||||||
|
Dark
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={theme === 'system' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('system')}
|
||||||
|
>
|
||||||
|
<Monitor className="h-4 w-4 mr-2" />
|
||||||
|
System
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Gateway */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gateway</CardTitle>
|
||||||
|
<CardDescription>OpenClaw Gateway settings</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Status</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Port: {gatewayStatus.port}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
gatewayStatus.state === 'running'
|
||||||
|
? 'success'
|
||||||
|
: gatewayStatus.state === 'error'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{gatewayStatus.state}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" onClick={restartGateway}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Auto-start Gateway</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Start Gateway when ClawX launches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={gatewayAutoStart}
|
||||||
|
onCheckedChange={setGatewayAutoStart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Updates */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Updates</CardTitle>
|
||||||
|
<CardDescription>Keep ClawX up to date</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<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">
|
||||||
|
Version {appVersion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCheckUpdate}
|
||||||
|
disabled={checkingUpdate}
|
||||||
|
>
|
||||||
|
{checkingUpdate ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Check for Updates'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Auto-check for updates</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Check for updates on startup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoCheckUpdate}
|
||||||
|
onCheckedChange={setAutoCheckUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Auto-download updates</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Download updates in the background
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoDownloadUpdate}
|
||||||
|
onCheckedChange={setAutoDownloadUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Developer */}
|
||||||
|
{devModeUnlocked && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Developer</CardTitle>
|
||||||
|
<CardDescription>Advanced options for developers</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>OpenClaw Console</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Access the native OpenClaw management interface
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={openDevConsole}>
|
||||||
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
|
Open Developer Console
|
||||||
|
<ExternalLink className="h-3 w-3 ml-2" />
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Opens http://localhost:18789 in your browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>About</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<strong>ClawX</strong> - Graphical AI Assistant
|
||||||
|
</p>
|
||||||
|
<p>Based on OpenClaw</p>
|
||||||
|
<p>Version {appVersion}</p>
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
onClick={() => window.electron.openExternal('https://docs.clawx.app')}
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
onClick={() => window.electron.openExternal('https://github.com/clawx/clawx')}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
323
src/pages/Setup/index.tsx
Normal file
323
src/pages/Setup/index.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Setup Wizard Page
|
||||||
|
* First-time setup experience for new users
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Check, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SetupStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: SetupStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
title: 'Welcome to ClawX',
|
||||||
|
description: 'Your AI assistant is ready to be configured',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime',
|
||||||
|
title: 'Environment Check',
|
||||||
|
description: 'Verifying system requirements',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'provider',
|
||||||
|
title: 'AI Provider',
|
||||||
|
description: 'Configure your AI service',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'channel',
|
||||||
|
title: 'Connect Channel',
|
||||||
|
description: 'Link a messaging app',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skills',
|
||||||
|
title: 'Choose Skills',
|
||||||
|
description: 'Select your skill bundles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'complete',
|
||||||
|
title: 'All Set!',
|
||||||
|
description: 'ClawX is ready to use',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Setup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
const step = steps[currentStep];
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
|
const isLastStep = currentStep === steps.length - 1;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (isLastStep) {
|
||||||
|
// Complete setup and go to dashboard
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setCurrentStep((i) => i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setCurrentStep((i) => Math.max(i - 1, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="flex justify-center pt-8">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={s.id} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
|
||||||
|
i < currentStep
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: i === currentStep
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-slate-600 text-slate-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i < currentStep ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{i + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-0.5 w-8 transition-colors',
|
||||||
|
i < currentStep ? 'bg-primary' : 'bg-slate-600'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={step.id}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="mx-auto max-w-2xl p-8"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">{step.title}</h1>
|
||||||
|
<p className="text-slate-400">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step-specific content */}
|
||||||
|
<div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8">
|
||||||
|
{currentStep === 0 && <WelcomeContent />}
|
||||||
|
{currentStep === 1 && <RuntimeContent />}
|
||||||
|
{currentStep === 2 && <ProviderContent />}
|
||||||
|
{currentStep === 3 && <ChannelContent />}
|
||||||
|
{currentStep === 4 && <SkillsContent />}
|
||||||
|
{currentStep === 5 && <CompleteContent />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
{!isFirstStep && (
|
||||||
|
<Button variant="ghost" onClick={handleBack}>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isLastStep && (
|
||||||
|
<Button variant="ghost" onClick={handleSkip}>
|
||||||
|
Skip Setup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleNext}>
|
||||||
|
{isLastStep ? (
|
||||||
|
'Get Started'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4 ml-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step content components (simplified versions)
|
||||||
|
function WelcomeContent() {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="text-6xl mb-4">🤖</div>
|
||||||
|
<h2 className="text-xl font-semibold">Welcome to ClawX</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
ClawX is a graphical interface for OpenClaw, making it easy to use AI
|
||||||
|
assistants across your favorite messaging platforms.
|
||||||
|
</p>
|
||||||
|
<ul className="text-left space-y-2 text-slate-300">
|
||||||
|
<li>✅ Zero command-line required</li>
|
||||||
|
<li>✅ Modern, beautiful interface</li>
|
||||||
|
<li>✅ Pre-installed skill bundles</li>
|
||||||
|
<li>✅ Cross-platform support</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuntimeContent() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Checking Environment</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||||
|
<span>Node.js Runtime</span>
|
||||||
|
<span className="text-green-400">✓ Installed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||||
|
<span>OpenClaw Package</span>
|
||||||
|
<span className="text-green-400">✓ Ready</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||||
|
<span>Gateway Service</span>
|
||||||
|
<span className="text-green-400">✓ Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderContent() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Select AI Provider</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
Choose your preferred AI model provider
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖' },
|
||||||
|
{ id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚' },
|
||||||
|
{ id: 'google', name: 'Google', model: 'Gemini', icon: '🔷' },
|
||||||
|
].map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-center"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{provider.icon}</span>
|
||||||
|
<p className="font-medium mt-2">{provider.name}</p>
|
||||||
|
<p className="text-sm text-slate-400">{provider.model}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelContent() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Connect a Channel</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
Link a messaging app to start chatting with your AI
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ type: 'whatsapp', name: 'WhatsApp', icon: '📱' },
|
||||||
|
{ type: 'telegram', name: 'Telegram', icon: '✈️' },
|
||||||
|
{ type: 'discord', name: 'Discord', icon: '🎮' },
|
||||||
|
{ type: 'slack', name: 'Slack', icon: '💼' },
|
||||||
|
].map((channel) => (
|
||||||
|
<button
|
||||||
|
key={channel.type}
|
||||||
|
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{channel.icon}</span>
|
||||||
|
<span className="font-medium">{channel.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400 text-center">
|
||||||
|
You can add more channels later in Settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillsContent() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Choose Skill Bundles</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
Select pre-configured skill packages
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ id: 'productivity', name: 'Productivity', icon: '📋', recommended: true },
|
||||||
|
{ id: 'developer', name: 'Developer', icon: '💻', recommended: true },
|
||||||
|
{ id: 'smart-home', name: 'Smart Home', icon: '🏠' },
|
||||||
|
{ id: 'media', name: 'Media', icon: '🎨' },
|
||||||
|
].map((bundle) => (
|
||||||
|
<button
|
||||||
|
key={bundle.id}
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-left relative',
|
||||||
|
bundle.recommended && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{bundle.icon}</span>
|
||||||
|
<p className="font-medium mt-2">{bundle.name}</p>
|
||||||
|
{bundle.recommended && (
|
||||||
|
<span className="absolute top-2 right-2 text-xs bg-primary px-2 py-0.5 rounded">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompleteContent() {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="text-6xl mb-4">🎉</div>
|
||||||
|
<h2 className="text-xl font-semibold">Setup Complete!</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
ClawX is configured and ready to use. You can now start chatting with
|
||||||
|
your AI assistant.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-slate-300">
|
||||||
|
<p>✅ AI Provider configured</p>
|
||||||
|
<p>✅ Channel connected</p>
|
||||||
|
<p>✅ Skills enabled</p>
|
||||||
|
<p>✅ Gateway running</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Setup;
|
||||||
198
src/pages/Skills/index.tsx
Normal file
198
src/pages/Skills/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Skills Page
|
||||||
|
* Browse and manage AI skills
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Search, Puzzle, RefreshCw, Lock } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useSkillsStore } from '@/stores/skills';
|
||||||
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SkillCategory } from '@/types/skill';
|
||||||
|
|
||||||
|
const categoryLabels: Record<SkillCategory, string> = {
|
||||||
|
productivity: 'Productivity',
|
||||||
|
developer: 'Developer',
|
||||||
|
'smart-home': 'Smart Home',
|
||||||
|
media: 'Media',
|
||||||
|
communication: 'Communication',
|
||||||
|
security: 'Security',
|
||||||
|
information: 'Information',
|
||||||
|
utility: 'Utility',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Skills() {
|
||||||
|
const { skills, loading, error, fetchSkills, enableSkill, disableSkill } = useSkillsStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<SkillCategory | 'all'>('all');
|
||||||
|
|
||||||
|
// Fetch skills on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSkills();
|
||||||
|
}, [fetchSkills]);
|
||||||
|
|
||||||
|
// Filter skills
|
||||||
|
const filteredSkills = skills.filter((skill) => {
|
||||||
|
const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesCategory = selectedCategory === 'all' || skill.category === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get unique categories
|
||||||
|
const categories = Array.from(new Set(skills.map((s) => s.category)));
|
||||||
|
|
||||||
|
// Handle toggle
|
||||||
|
const handleToggle = async (skillId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
await disableSkill(skillId);
|
||||||
|
} else {
|
||||||
|
await enableSkill(skillId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-96 items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Skills</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Browse and manage AI skills
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={fetchSkills}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filter */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search skills..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant={selectedCategory === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCategory('all')}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Button
|
||||||
|
key={category}
|
||||||
|
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
>
|
||||||
|
{categoryLabels[category]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="py-4 text-destructive">
|
||||||
|
{error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills Grid */}
|
||||||
|
{filteredSkills.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Puzzle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">No skills found</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{searchQuery ? 'Try a different search term' : 'No skills available'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredSkills.map((skill) => (
|
||||||
|
<Card key={skill.id} className={cn(skill.enabled && 'border-primary/50')}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{skill.icon || '🔧'}</span>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
{skill.name}
|
||||||
|
{skill.isCore && (
|
||||||
|
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{categoryLabels[skill.category]}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={skill.enabled}
|
||||||
|
onCheckedChange={() => handleToggle(skill.id, skill.enabled)}
|
||||||
|
disabled={skill.isCore}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
{skill.version && (
|
||||||
|
<Badge variant="outline" className="mt-2 text-xs">
|
||||||
|
v{skill.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{skills.filter((s) => s.isCore).length} core skills
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skills;
|
||||||
90
src/stores/channels.ts
Normal file
90
src/stores/channels.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Channels State Store
|
||||||
|
* Manages messaging channel state
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Channel } from '../types/channel';
|
||||||
|
|
||||||
|
interface ChannelsState {
|
||||||
|
channels: Channel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchChannels: () => Promise<void>;
|
||||||
|
connectChannel: (channelId: string) => Promise<void>;
|
||||||
|
disconnectChannel: (channelId: string) => Promise<void>;
|
||||||
|
setChannels: (channels: Channel[]) => void;
|
||||||
|
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||||
|
channels: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchChannels: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.list'
|
||||||
|
) as { success: boolean; result?: Channel[]; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
set({ channels: result.result, loading: false });
|
||||||
|
} else {
|
||||||
|
set({ error: result.error || 'Failed to fetch channels', loading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connectChannel: async (channelId) => {
|
||||||
|
const { updateChannel } = get();
|
||||||
|
updateChannel(channelId, { status: 'connecting' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.connect',
|
||||||
|
{ channelId }
|
||||||
|
) as { success: boolean; error?: string };
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateChannel(channelId, { status: 'connected' });
|
||||||
|
} else {
|
||||||
|
updateChannel(channelId, { status: 'error', error: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
updateChannel(channelId, { status: 'error', error: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectChannel: async (channelId) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'channels.disconnect',
|
||||||
|
{ channelId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { updateChannel } = get();
|
||||||
|
updateChannel(channelId, { status: 'disconnected' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect channel:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setChannels: (channels) => set({ channels }),
|
||||||
|
|
||||||
|
updateChannel: (channelId, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
channels: state.channels.map((channel) =>
|
||||||
|
channel.id === channelId ? { ...channel, ...updates } : channel
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
129
src/stores/chat.ts
Normal file
129
src/stores/chat.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Chat State Store
|
||||||
|
* Manages chat messages and conversation state
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call in a message
|
||||||
|
*/
|
||||||
|
interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat message
|
||||||
|
*/
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
channel?: string;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
loading: boolean;
|
||||||
|
sending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchHistory: (limit?: number) => Promise<void>;
|
||||||
|
sendMessage: (content: string, channelId?: string) => Promise<void>;
|
||||||
|
clearHistory: () => Promise<void>;
|
||||||
|
addMessage: (message: ChatMessage) => void;
|
||||||
|
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
||||||
|
setMessages: (messages: ChatMessage[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatState>((set, get) => ({
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
sending: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchHistory: async (limit = 50) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.history',
|
||||||
|
{ limit, offset: 0 }
|
||||||
|
) as { success: boolean; result?: ChatMessage[]; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
set({ messages: result.result, loading: false });
|
||||||
|
} else {
|
||||||
|
set({ error: result.error || 'Failed to fetch history', loading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (content, channelId) => {
|
||||||
|
const { addMessage } = get();
|
||||||
|
|
||||||
|
// Add user message immediately
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
channel: channelId,
|
||||||
|
};
|
||||||
|
addMessage(userMessage);
|
||||||
|
|
||||||
|
set({ sending: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'chat.send',
|
||||||
|
{ content, channelId }
|
||||||
|
) as { success: boolean; result?: ChatMessage; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
addMessage(result.result);
|
||||||
|
} else {
|
||||||
|
set({ error: result.error || 'Failed to send message' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
} finally {
|
||||||
|
set({ sending: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory: async () => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('gateway:rpc', 'chat.clear');
|
||||||
|
set({ messages: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear history:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMessage: (message) => {
|
||||||
|
set((state) => ({
|
||||||
|
messages: [...state.messages, message],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessage: (messageId, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
messages: state.messages.map((msg) =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updates } : msg
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setMessages: (messages) => set({ messages }),
|
||||||
|
}));
|
||||||
100
src/stores/cron.ts
Normal file
100
src/stores/cron.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Cron State Store
|
||||||
|
* Manages scheduled task state
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||||
|
|
||||||
|
interface CronState {
|
||||||
|
jobs: CronJob[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchJobs: () => Promise<void>;
|
||||||
|
createJob: (input: CronJobCreateInput) => Promise<CronJob>;
|
||||||
|
updateJob: (id: string, input: CronJobUpdateInput) => Promise<void>;
|
||||||
|
deleteJob: (id: string) => Promise<void>;
|
||||||
|
toggleJob: (id: string, enabled: boolean) => Promise<void>;
|
||||||
|
triggerJob: (id: string) => Promise<void>;
|
||||||
|
setJobs: (jobs: CronJob[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCronStore = create<CronState>((set, get) => ({
|
||||||
|
jobs: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchJobs: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||||
|
set({ jobs: result, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createJob: async (input) => {
|
||||||
|
try {
|
||||||
|
const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob;
|
||||||
|
set((state) => ({ jobs: [...state.jobs, job] }));
|
||||||
|
return job;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateJob: async (id, input) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('cron:update', id, input);
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((job) =>
|
||||||
|
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteJob: async (id) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('cron:delete', id);
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.filter((job) => job.id !== id),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleJob: async (id, enabled) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled);
|
||||||
|
set((state) => ({
|
||||||
|
jobs: state.jobs.map((job) =>
|
||||||
|
job.id === id ? { ...job, enabled } : job
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerJob: async (id) => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setJobs: (jobs) => set({ jobs }),
|
||||||
|
}));
|
||||||
73
src/stores/gateway.ts
Normal file
73
src/stores/gateway.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Gateway State Store
|
||||||
|
* Manages Gateway connection state
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GatewayStatus } from '../types/gateway';
|
||||||
|
|
||||||
|
interface GatewayState {
|
||||||
|
status: GatewayStatus;
|
||||||
|
isInitialized: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
init: () => Promise<void>;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
restart: () => Promise<void>;
|
||||||
|
setStatus: (status: GatewayStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||||
|
status: {
|
||||||
|
state: 'stopped',
|
||||||
|
port: 18789,
|
||||||
|
},
|
||||||
|
isInitialized: false,
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
if (get().isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get initial status
|
||||||
|
const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus;
|
||||||
|
set({ status, isInitialized: true });
|
||||||
|
|
||||||
|
// Listen for status changes
|
||||||
|
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
|
||||||
|
set({ status: newStatus as GatewayStatus });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Gateway:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
start: async () => {
|
||||||
|
try {
|
||||||
|
set({ status: { ...get().status, state: 'starting' } });
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string };
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
set({ status: { ...get().status, state: 'error', error: result.error } });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ status: { ...get().status, state: 'error', error: String(error) } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: async () => {
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('gateway:stop');
|
||||||
|
set({ status: { ...get().status, state: 'stopped' } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop Gateway:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restart: async () => {
|
||||||
|
const { stop, start } = get();
|
||||||
|
await stop();
|
||||||
|
await start();
|
||||||
|
},
|
||||||
|
|
||||||
|
setStatus: (status) => set({ status }),
|
||||||
|
}));
|
||||||
82
src/stores/settings.ts
Normal file
82
src/stores/settings.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Settings State Store
|
||||||
|
* Manages application settings
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
// General
|
||||||
|
theme: Theme;
|
||||||
|
language: string;
|
||||||
|
startMinimized: boolean;
|
||||||
|
launchAtStartup: boolean;
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
gatewayAutoStart: boolean;
|
||||||
|
gatewayPort: number;
|
||||||
|
|
||||||
|
// Update
|
||||||
|
updateChannel: UpdateChannel;
|
||||||
|
autoCheckUpdate: boolean;
|
||||||
|
autoDownloadUpdate: boolean;
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
devModeUnlocked: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
setLanguage: (language: string) => void;
|
||||||
|
setStartMinimized: (value: boolean) => void;
|
||||||
|
setLaunchAtStartup: (value: boolean) => void;
|
||||||
|
setGatewayAutoStart: (value: boolean) => void;
|
||||||
|
setGatewayPort: (port: number) => void;
|
||||||
|
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||||
|
setAutoCheckUpdate: (value: boolean) => void;
|
||||||
|
setAutoDownloadUpdate: (value: boolean) => void;
|
||||||
|
setSidebarCollapsed: (value: boolean) => void;
|
||||||
|
setDevModeUnlocked: (value: boolean) => void;
|
||||||
|
resetSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings = {
|
||||||
|
theme: 'system' as Theme,
|
||||||
|
language: 'en',
|
||||||
|
startMinimized: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
gatewayAutoStart: true,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
updateChannel: 'stable' as UpdateChannel,
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
autoDownloadUpdate: false,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
devModeUnlocked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...defaultSettings,
|
||||||
|
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
setLanguage: (language) => set({ language }),
|
||||||
|
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||||
|
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||||
|
setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }),
|
||||||
|
setGatewayPort: (gatewayPort) => set({ gatewayPort }),
|
||||||
|
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||||
|
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||||
|
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||||
|
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||||
|
setDevModeUnlocked: (devModeUnlocked) => set({ devModeUnlocked }),
|
||||||
|
resetSettings: () => set(defaultSettings),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'clawx-settings',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
102
src/stores/skills.ts
Normal file
102
src/stores/skills.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Skills State Store
|
||||||
|
* Manages skill/plugin state
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Skill } from '../types/skill';
|
||||||
|
|
||||||
|
interface SkillsState {
|
||||||
|
skills: Skill[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchSkills: () => Promise<void>;
|
||||||
|
enableSkill: (skillId: string) => Promise<void>;
|
||||||
|
disableSkill: (skillId: string) => Promise<void>;
|
||||||
|
setSkills: (skills: Skill[]) => void;
|
||||||
|
updateSkill: (skillId: string, updates: Partial<Skill>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||||
|
skills: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchSkills: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'skills.list'
|
||||||
|
) as { success: boolean; result?: Skill[]; error?: string };
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
set({ skills: result.result, loading: false });
|
||||||
|
} else {
|
||||||
|
set({ error: result.error || 'Failed to fetch skills', loading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
enableSkill: async (skillId) => {
|
||||||
|
const { updateSkill } = get();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'skills.enable',
|
||||||
|
{ skillId }
|
||||||
|
) as { success: boolean; error?: string };
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateSkill(skillId, { enabled: true });
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to enable skill');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable skill:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disableSkill: async (skillId) => {
|
||||||
|
const { updateSkill, skills } = get();
|
||||||
|
|
||||||
|
// Check if skill is a core skill
|
||||||
|
const skill = skills.find((s) => s.id === skillId);
|
||||||
|
if (skill?.isCore) {
|
||||||
|
throw new Error('Cannot disable core skill');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
|
'gateway:rpc',
|
||||||
|
'skills.disable',
|
||||||
|
{ skillId }
|
||||||
|
) as { success: boolean; error?: string };
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateSkill(skillId, { enabled: false });
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to disable skill');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disable skill:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSkills: (skills) => set({ skills }),
|
||||||
|
|
||||||
|
updateSkill: (skillId, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
skills: state.skills.map((skill) =>
|
||||||
|
skill.id === skillId ? { ...skill, ...updates } : skill
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
97
src/styles/globals.css
Normal file
97
src/styles/globals.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-border rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-muted-foreground/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* macOS traffic light spacing */
|
||||||
|
.drag-region {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.transition-theme {
|
||||||
|
@apply transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
|
||||||
|
}
|
||||||
74
src/types/channel.ts
Normal file
74
src/types/channel.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Channel Type Definitions
|
||||||
|
* Types for messaging channels (WhatsApp, Telegram, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported channel types
|
||||||
|
*/
|
||||||
|
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel connection status
|
||||||
|
*/
|
||||||
|
export type ChannelStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel data structure
|
||||||
|
*/
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
type: ChannelType;
|
||||||
|
name: string;
|
||||||
|
status: ChannelStatus;
|
||||||
|
lastActivity?: string;
|
||||||
|
error?: string;
|
||||||
|
avatar?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel configuration for each type
|
||||||
|
*/
|
||||||
|
export interface ChannelConfig {
|
||||||
|
whatsapp: {
|
||||||
|
phoneNumber?: string;
|
||||||
|
};
|
||||||
|
telegram: {
|
||||||
|
botToken?: string;
|
||||||
|
chatId?: string;
|
||||||
|
};
|
||||||
|
discord: {
|
||||||
|
botToken?: string;
|
||||||
|
guildId?: string;
|
||||||
|
};
|
||||||
|
slack: {
|
||||||
|
botToken?: string;
|
||||||
|
appToken?: string;
|
||||||
|
};
|
||||||
|
wechat: {
|
||||||
|
appId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel icons mapping
|
||||||
|
*/
|
||||||
|
export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||||
|
whatsapp: '📱',
|
||||||
|
telegram: '✈️',
|
||||||
|
discord: '🎮',
|
||||||
|
slack: '💼',
|
||||||
|
wechat: '💬',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel display names
|
||||||
|
*/
|
||||||
|
export const CHANNEL_NAMES: Record<ChannelType, string> = {
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
telegram: 'Telegram',
|
||||||
|
discord: 'Discord',
|
||||||
|
slack: 'Slack',
|
||||||
|
wechat: 'WeChat',
|
||||||
|
};
|
||||||
68
src/types/cron.ts
Normal file
68
src/types/cron.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Cron Job Type Definitions
|
||||||
|
* Types for scheduled tasks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChannelType } from './channel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron job target (where to send the result)
|
||||||
|
*/
|
||||||
|
export interface CronJobTarget {
|
||||||
|
channelType: ChannelType;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron job last run info
|
||||||
|
*/
|
||||||
|
export interface CronJobLastRun {
|
||||||
|
time: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron job data structure
|
||||||
|
*/
|
||||||
|
export interface CronJob {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
schedule: string;
|
||||||
|
target: CronJobTarget;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastRun?: CronJobLastRun;
|
||||||
|
nextRun?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for creating a cron job
|
||||||
|
*/
|
||||||
|
export interface CronJobCreateInput {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
schedule: string;
|
||||||
|
target: CronJobTarget;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for updating a cron job
|
||||||
|
*/
|
||||||
|
export interface CronJobUpdateInput {
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
schedule?: string;
|
||||||
|
target?: CronJobTarget;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule type for UI picker
|
||||||
|
*/
|
||||||
|
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';
|
||||||
26
src/types/electron.d.ts
vendored
Normal file
26
src/types/electron.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Electron API Type Declarations
|
||||||
|
* Types for the APIs exposed via contextBridge
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IpcRenderer {
|
||||||
|
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
|
||||||
|
on(channel: string, callback: (...args: unknown[]) => void): (() => void) | void;
|
||||||
|
once(channel: string, callback: (...args: unknown[]) => void): void;
|
||||||
|
off(channel: string, callback?: (...args: unknown[]) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronAPI {
|
||||||
|
ipcRenderer: IpcRenderer;
|
||||||
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
isDev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
25
src/types/gateway.ts
Normal file
25
src/types/gateway.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Gateway Type Definitions
|
||||||
|
* Types for Gateway communication and data structures
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway connection status
|
||||||
|
*/
|
||||||
|
export interface GatewayStatus {
|
||||||
|
state: 'stopped' | 'starting' | 'running' | 'error';
|
||||||
|
port: number;
|
||||||
|
pid?: number;
|
||||||
|
uptime?: number;
|
||||||
|
error?: string;
|
||||||
|
connectedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway RPC response
|
||||||
|
*/
|
||||||
|
export interface GatewayRpcResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
result?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
64
src/types/skill.ts
Normal file
64
src/types/skill.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Skill Type Definitions
|
||||||
|
* Types for skills/plugins
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill category
|
||||||
|
*/
|
||||||
|
export type SkillCategory =
|
||||||
|
| 'productivity'
|
||||||
|
| 'developer'
|
||||||
|
| 'smart-home'
|
||||||
|
| 'media'
|
||||||
|
| 'communication'
|
||||||
|
| 'security'
|
||||||
|
| 'information'
|
||||||
|
| 'utility'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill data structure
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
category: SkillCategory;
|
||||||
|
icon?: string;
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
configurable?: boolean;
|
||||||
|
isCore?: boolean;
|
||||||
|
dependencies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill bundle (preset skill collection)
|
||||||
|
*/
|
||||||
|
export interface SkillBundle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameZh: string;
|
||||||
|
description: string;
|
||||||
|
descriptionZh: string;
|
||||||
|
icon: string;
|
||||||
|
skills: string[];
|
||||||
|
recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill configuration schema
|
||||||
|
*/
|
||||||
|
export interface SkillConfigSchema {
|
||||||
|
type: 'object';
|
||||||
|
properties: Record<string, {
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'array';
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
default?: unknown;
|
||||||
|
enum?: unknown[];
|
||||||
|
}>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
74
tailwind.config.js
Normal file
74
tailwind.config.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: '0' },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
44
tests/setup.ts
Normal file
44
tests/setup.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Vitest Test Setup
|
||||||
|
* Global test configuration and mocks
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Mock window.electron API
|
||||||
|
const mockElectron = {
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
},
|
||||||
|
openExternal: vi.fn(),
|
||||||
|
platform: 'darwin',
|
||||||
|
isDev: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'electron', {
|
||||||
|
value: mockElectron,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset mocks after each test
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
75
tests/unit/stores.test.ts
Normal file
75
tests/unit/stores.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Zustand Stores Tests
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
|
|
||||||
|
describe('Settings Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store to default state
|
||||||
|
useSettingsStore.setState({
|
||||||
|
theme: 'system',
|
||||||
|
language: 'en',
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
devModeUnlocked: false,
|
||||||
|
gatewayAutoStart: true,
|
||||||
|
gatewayPort: 18789,
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
autoDownloadUpdate: false,
|
||||||
|
startMinimized: false,
|
||||||
|
launchAtStartup: false,
|
||||||
|
updateChannel: 'stable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default values', () => {
|
||||||
|
const state = useSettingsStore.getState();
|
||||||
|
expect(state.theme).toBe('system');
|
||||||
|
expect(state.sidebarCollapsed).toBe(false);
|
||||||
|
expect(state.gatewayAutoStart).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update theme', () => {
|
||||||
|
const { setTheme } = useSettingsStore.getState();
|
||||||
|
setTheme('dark');
|
||||||
|
expect(useSettingsStore.getState().theme).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle sidebar collapsed state', () => {
|
||||||
|
const { setSidebarCollapsed } = useSettingsStore.getState();
|
||||||
|
setSidebarCollapsed(true);
|
||||||
|
expect(useSettingsStore.getState().sidebarCollapsed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unlock dev mode', () => {
|
||||||
|
const { setDevModeUnlocked } = useSettingsStore.getState();
|
||||||
|
setDevModeUnlocked(true);
|
||||||
|
expect(useSettingsStore.getState().devModeUnlocked).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gateway Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store
|
||||||
|
useGatewayStore.setState({
|
||||||
|
status: { state: 'stopped', port: 18789 },
|
||||||
|
isInitialized: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default status', () => {
|
||||||
|
const state = useGatewayStore.getState();
|
||||||
|
expect(state.status.state).toBe('stopped');
|
||||||
|
expect(state.status.port).toBe(18789);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update status', () => {
|
||||||
|
const { setStatus } = useGatewayStore.getState();
|
||||||
|
setStatus({ state: 'running', port: 18789, pid: 12345 });
|
||||||
|
|
||||||
|
const state = useGatewayStore.getState();
|
||||||
|
expect(state.status.state).toBe('running');
|
||||||
|
expect(state.status.pid).toBe(12345);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/unit/utils.test.ts
Normal file
45
tests/unit/utils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Utility Functions Tests
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cn, formatRelativeTime, formatDuration, truncate } from '@/lib/utils';
|
||||||
|
|
||||||
|
describe('cn (class name merge)', () => {
|
||||||
|
it('should merge class names', () => {
|
||||||
|
expect(cn('foo', 'bar')).toBe('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional classes', () => {
|
||||||
|
expect(cn('base', true && 'active')).toBe('base active');
|
||||||
|
expect(cn('base', false && 'active')).toBe('base');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge tailwind classes correctly', () => {
|
||||||
|
expect(cn('px-2', 'px-4')).toBe('px-4');
|
||||||
|
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('should format seconds only', () => {
|
||||||
|
expect(formatDuration(45)).toBe('45s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format minutes and seconds', () => {
|
||||||
|
expect(formatDuration(125)).toBe('2m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format hours and minutes', () => {
|
||||||
|
expect(formatDuration(3725)).toBe('1h 2m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('truncate', () => {
|
||||||
|
it('should not truncate short text', () => {
|
||||||
|
expect(truncate('Hello', 10)).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long text with ellipsis', () => {
|
||||||
|
expect(truncate('Hello World!', 8)).toBe('Hello...');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@electron/*": ["electron/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
30
tsconfig.node.json
Normal file
30
tsconfig.node.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist-electron",
|
||||||
|
"declaration": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@electron/*": ["electron/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["electron", "vite.config.ts"]
|
||||||
|
}
|
||||||
58
vite.config.ts
Normal file
58
vite.config.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import electron from 'vite-plugin-electron';
|
||||||
|
import renderer from 'vite-plugin-electron-renderer';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
electron([
|
||||||
|
{
|
||||||
|
// Main process entry file
|
||||||
|
entry: 'electron/main/index.ts',
|
||||||
|
onstart(options) {
|
||||||
|
options.startup();
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron/main',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron', 'electron-store', 'electron-updater', 'ws'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Preload scripts entry file
|
||||||
|
entry: 'electron/preload/index.ts',
|
||||||
|
onstart(options) {
|
||||||
|
options.reload();
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
renderer(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@electron': resolve(__dirname, 'electron'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
23
vitest.config.ts
Normal file
23
vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: ['node_modules/', 'tests/'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@electron': resolve(__dirname, 'electron'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user