Merge pull request #1 from intellispectrum/0.1.0-dev

dev-0.1.0
This commit is contained in:
Haze
2026-02-06 05:57:43 +08:00
committed by GitHub
Unverified
96 changed files with 21532 additions and 23 deletions

20
.env.example Normal file
View 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

122
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
# ClawX CI Workflow
# Runs on pull requests and pushes to main
name: CI
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linter
run: pnpm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run typecheck
run: pnpm run typecheck
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm run test
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build:vite
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

145
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,145 @@
# ClawX Release Workflow
# Builds and publishes releases for macOS, Windows, and Linux
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
permissions:
contents: write
jobs:
release:
strategy:
matrix:
include:
- os: macos-latest
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Vite
run: pnpm run build:vite
# macOS specific steps
- name: Build macOS
if: matrix.platform == 'mac'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# For code signing (optional)
# CSC_LINK: ${{ secrets.MAC_CERTS }}
# CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# For notarization (optional)
# APPLE_ID: ${{ secrets.APPLE_ID }}
# APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
# APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: pnpm run package:mac
# Windows specific steps
- name: Build Windows
if: matrix.platform == 'win'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# For code signing (optional)
# CSC_LINK: ${{ secrets.WIN_CERTS }}
# CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTS_PASSWORD }}
run: pnpm run package:win
# Linux specific steps
- name: Build Linux
if: matrix.platform == 'linux'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run package:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.platform }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.AppImage
release/*.deb
release/*.yml
release/*.yaml
retention-days: 7
publish:
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts
- name: List artifacts
run: ls -la release-artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
release-artifacts/**/*.dmg
release-artifacts/**/*.zip
release-artifacts/**/*.exe
release-artifacts/**/*.AppImage
release-artifacts/**/*.deb
release-artifacts/**/*.yml
release-artifacts/**/*.yaml
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

57
.gitignore vendored Normal file
View 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

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "openclaw"]
path = openclaw
url = https://github.com/openclaw/openclaw.git

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

View File

@@ -63,7 +63,7 @@
-**版本绑定** - 每个 ClawX 版本绑定特定 openclaw 版本
-**CLI 兼容** - 命令行保持 `openclaw` 命令,不引入 `clawx` CLI
openclaw project Local: /Users/guoyuliang/Project/openclaw remote: https://github.com/openclaw/openclaw
openclaw project remote: https://github.com/openclaw/openclaw
### 1.4 CLI 兼容性设计
ClawX 是 OpenClaw 的**图形化增强层**,而非替代品。用户可以同时使用 GUI 和 CLI
@@ -576,13 +576,13 @@ clawx/
│ │ │ ├── index.tsx
│ │ │ ├── GeneralSettings.tsx
│ │ │ ├── ProviderSettings.tsx
│ │ │ ├── ChannelsSettings.tsx # 通道连接配置 (从安装向导移出)
│ │ │ └── AdvancedSettings.tsx
│ │ └── Setup/ # 安装向导
│ │ └── Setup/ # 安装向导 (简化版,不含通道连接)
│ │ ├── index.tsx
│ │ ├── WelcomeStep.tsx
│ │ ├── RuntimeStep.tsx
│ │ ├── ProviderStep.tsx
│ │ ├── ChannelStep.tsx
│ │ └── SkillStep.tsx
│ │
│ ├── components/ # 通用组件
@@ -812,17 +812,15 @@ const steps: SetupStep[] = [
description: '配置您的 AI 服务提供商',
component: ProviderStep,
},
// NOTE: Channel step removed - 通道连接移至 Settings > Channels 页面
// 用户可在完成初始设置后自行配置消息通道
// NOTE: Skills selection step removed - 自动安装必要组件
// 用户无需手动选择,核心组件自动安装
{
id: 'channel',
title: '连接消息应用',
description: '绑定 WhatsApp、Telegram 等',
component: ChannelStep,
},
{
id: 'skills',
title: '选择技能包',
description: '挑选预装技能,稍后可调整',
component: SkillStep,
id: 'installing',
title: '安装组件',
description: '正在安装必要的 AI 组件',
component: InstallingStep,
},
{
id: 'complete',
@@ -4042,13 +4040,15 @@ ClawX 版本: X.Y.Z[-prerelease]
| Node.js 自动检测/安装 | P0 | ⬜ |
| openclaw npm 安装 | P0 | ⬜ |
| Provider 配置 (API Key) | P0 | ⬜ |
| 首个通道连接 (WhatsApp QR) | P1 | ⬜ |
| 错误处理与提示 | P1 | ⬜ |
> **注意**: 通道连接功能 (WhatsApp/Telegram 等) 已从安装向导移至 Settings > Channels 页面。
> 用户可在完成初始设置后,根据需要自行配置消息通道,降低首次使用门槛。
**交付物**:
- 完整安装向导流程
- 简化版安装向导流程 (不含通道连接)
- 支持 macOS (Apple Silicon + Intel)
- 可配置 Anthropic/OpenAI
- 可配置 Anthropic/OpenAI/OpenRouter
---

123
README.md
View File

@@ -1 +1,124 @@
# 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/ValueCell-ai/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

View File

@@ -1 +0,0 @@
add `ClawX-项目架构与版本大纲.md` and init project.

View File

@@ -1,6 +0,0 @@
# Before:
* add `ClawX-项目架构与版本大纲.md`
# Plan:
Initialize the project structure according to `ClawX-项目架构与版本大纲.md`, and add the fundamental dependencies as specified in the documentation and technology stack.

155
electron-builder.yml Normal file
View File

@@ -0,0 +1,155 @@
appId: app.clawx.desktop
productName: ClawX
copyright: Copyright © 2026 ClawX
compression: maximum
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
directories:
output: release
buildResources: resources
files:
- dist
- dist-electron
- package.json
extraResources:
- from: resources/
to: resources/
filter:
- "**/*"
- "!icons/*.md"
- "!icons/*.svg"
# OpenClaw submodule - include only necessary files for runtime
- from: openclaw/
to: openclaw/
filter:
- "openclaw.mjs"
- "package.json"
- "dist/**/*"
- "skills/**/*"
- "extensions/**/*"
- "scripts/run-node.mjs"
- "!**/*.test.ts"
- "!**/*.test.js"
- "!**/test/**"
- "!**/.git"
- "!**/.github"
- "!**/docs/**"
asar: true
asarUnpack:
- "**/*.node"
# Auto-update configuration
publish:
- provider: github
owner: clawx
repo: clawx
releaseType: release
# macOS Configuration
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
notarize: false # Set to true when you have Apple credentials
extendInfo:
NSMicrophoneUsageDescription: ClawX requires microphone access for voice features
NSCameraUsageDescription: ClawX requires camera access for video features
dmg:
background: resources/dmg-background.png
icon: resources/icons/icon.icns
iconSize: 100
contents:
- type: file
x: 130
y: 220
- type: link
path: /Applications
x: 410
y: 220
# Windows Configuration
win:
icon: resources/icons/icon.ico
target:
- target: nsis
arch:
- x64
- arm64
publisherName: ClawX Inc.
# For code signing, uncomment and configure:
# certificateFile: path/to/certificate.pfx
# certificatePassword: ${env.WIN_CSC_KEY_PASSWORD}
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
differentialPackage: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: ClawX
uninstallDisplayName: ClawX
license: LICENSE
installerIcon: resources/icons/icon.ico
uninstallerIcon: resources/icons/icon.ico
# Linux Configuration
linux:
icon: resources/icons
target:
- target: AppImage
arch:
- x64
- arm64
- target: deb
arch:
- x64
- arm64
- target: rpm
arch:
- x64
category: Utility
maintainer: ClawX Team <team@clawx.app>
vendor: ClawX
synopsis: AI Assistant powered by OpenClaw
description: |
ClawX is a graphical AI assistant application that integrates with
OpenClaw Gateway to provide intelligent automation and assistance
across multiple messaging platforms.
desktop:
Name: ClawX
Comment: AI Assistant powered by OpenClaw
Categories: Utility;Network;
Keywords: ai;assistant;automation;chat;
appImage:
license: LICENSE
deb:
depends:
- libgtk-3-0
- libnotify4
- libnss3
- libxss1
- libxtst6
- xdg-utils
- libatspi2.0-0
- libuuid1
afterInstall: scripts/linux/after-install.sh
afterRemove: scripts/linux/after-remove.sh

333
electron/gateway/client.ts Normal file
View File

@@ -0,0 +1,333 @@
/**
* Gateway WebSocket Client
* Provides a typed interface for Gateway RPC calls
*/
import { GatewayManager, GatewayStatus } 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;
config?: Record<string, unknown>;
}
/**
* Skill definition
*/
export interface Skill {
id: string;
name: string;
description: string;
enabled: boolean;
category?: string;
icon?: string;
configurable?: boolean;
version?: string;
author?: string;
}
/**
* Skill bundle definition
*/
export interface SkillBundle {
id: string;
name: string;
description: string;
skills: string[];
icon?: string;
recommended?: boolean;
}
/**
* Chat message
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
channel?: string;
toolCalls?: ToolCall[];
metadata?: Record<string, unknown>;
}
/**
* Tool call in a message
*/
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
status: 'pending' | 'running' | 'completed' | 'error';
duration?: number;
}
/**
* Cron task definition
*/
export interface CronTask {
id: string;
name: string;
schedule: string;
command: string;
enabled: boolean;
lastRun?: string;
nextRun?: string;
status: 'idle' | 'running' | 'error';
error?: string;
}
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
apiKey?: string;
baseUrl?: string;
model?: string;
enabled: boolean;
}
/**
* Gateway Client
* Typed wrapper around GatewayManager for making RPC calls
*/
export class GatewayClient {
constructor(private manager: GatewayManager) {}
/**
* Get current gateway status
*/
getStatus(): GatewayStatus {
return this.manager.getStatus();
}
/**
* Check if gateway is connected
*/
isConnected(): boolean {
return this.manager.isConnected();
}
// ==================== 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');
}
// ==================== Cron Methods ====================
/**
* List all cron tasks
*/
async listCronTasks(): Promise<CronTask[]> {
return this.manager.rpc<CronTask[]>('cron.list');
}
/**
* Create a new cron task
*/
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.create', task);
}
/**
* Update a cron task
*/
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
}
/**
* Delete a cron task
*/
async deleteCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.delete', { taskId });
}
/**
* Run a cron task immediately
*/
async runCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.run', { taskId });
}
// ==================== Provider Methods ====================
/**
* List configured AI providers
*/
async listProviders(): Promise<ProviderConfig[]> {
return this.manager.rpc<ProviderConfig[]>('providers.list');
}
/**
* Add or update a provider
*/
async setProvider(provider: ProviderConfig): Promise<void> {
return this.manager.rpc<void>('providers.set', provider);
}
/**
* Remove a provider
*/
async removeProvider(providerId: string): Promise<void> {
return this.manager.rpc<void>('providers.remove', { providerId });
}
/**
* Test provider connection
*/
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
}
// ==================== System Methods ====================
/**
* Get Gateway health status
*/
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('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);
}
/**
* Get Gateway version info
*/
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
}
/**
* Get available skill bundles
*/
async getSkillBundles(): Promise<SkillBundle[]> {
return this.manager.rpc<SkillBundle[]>('skills.bundles');
}
/**
* Install a skill bundle
*/
async installBundle(bundleId: string): Promise<void> {
return this.manager.rpc<void>('skills.installBundle', { bundleId });
}
}

809
electron/gateway/manager.ts Normal file
View File

@@ -0,0 +1,809 @@
/**
* Gateway Process Manager
* Manages the OpenClaw Gateway process lifecycle
*/
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { existsSync } from 'fs';
import WebSocket from 'ws';
import { PORTS } from '../utils/config';
import {
getOpenClawDir,
getOpenClawEntryPath,
isOpenClawBuilt,
isOpenClawSubmodulePresent,
isOpenClawInstalled
} from '../utils/paths';
import { getSetting } from '../utils/store';
import { getApiKey } from '../utils/secure-storage';
import { getProviderEnvVar } from '../utils/openclaw-auth';
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
/**
* Gateway connection status
*/
export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
port: number;
pid?: number;
uptime?: number;
error?: string;
connectedAt?: number;
version?: string;
reconnectAttempts?: number;
}
/**
* Gateway Manager Events
*/
export interface GatewayManagerEvents {
status: (status: GatewayStatus) => void;
message: (message: unknown) => void;
notification: (notification: JsonRpcNotification) => void;
exit: (code: number | null) => void;
error: (error: Error) => void;
'channel:status': (data: { channelId: string; status: string }) => void;
'chat:message': (data: { message: unknown }) => void;
}
/**
* Reconnection configuration
*/
interface ReconnectConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
}
const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxAttempts: 10,
baseDelay: 1000,
maxDelay: 30000,
};
/**
* 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 healthCheckInterval: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private reconnectConfig: ReconnectConfig;
private shouldReconnect = true;
private pendingRequests: Map<string, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}> = new Map();
constructor(config?: Partial<ReconnectConfig>) {
super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
}
/**
* Get current Gateway status
*/
getStatus(): GatewayStatus {
return { ...this.status };
}
/**
* Check if Gateway is connected and ready
*/
isConnected(): boolean {
return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN;
}
/**
* Start Gateway process
*/
async start(): Promise<void> {
if (this.status.state === 'running') {
return;
}
this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.setStatus({ state: 'starting', reconnectAttempts: 0 });
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);
this.startHealthCheck();
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);
// Start health monitoring
this.startHealthCheck();
} catch (error) {
this.setStatus({ state: 'error', error: String(error) });
throw error;
}
}
/**
* Stop Gateway process
*/
async stop(): Promise<void> {
// Disable auto-reconnect
this.shouldReconnect = false;
// Clear all timers
this.clearAllTimers();
// Close WebSocket
if (this.ws) {
this.ws.close(1000, 'Gateway stopped by user');
this.ws = null;
}
// Kill process
if (this.process) {
this.process.kill('SIGTERM');
// Force kill after timeout
setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
this.process = null;
}
}, 5000);
this.process = null;
}
// Reject all pending requests
for (const [, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Gateway stopped'));
}
this.pendingRequests.clear();
this.setStatus({ state: 'stopped', error: undefined });
}
/**
* Restart Gateway process
*/
async restart(): Promise<void> {
console.log('Restarting Gateway...');
await this.stop();
// Brief delay before restart
await new Promise(resolve => setTimeout(resolve, 1000));
await this.start();
}
/**
* Clear all active timers
*/
private clearAllTimers(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
/**
* Make an RPC call to the Gateway
* Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} }
*/
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): 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}`));
}, timeoutMs);
// Store pending request
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
// Send request using OpenClaw protocol format
const request = {
type: 'req',
id,
method,
params,
};
try {
this.ws.send(JSON.stringify(request));
} catch (error) {
this.pendingRequests.delete(id);
clearTimeout(timeout);
reject(new Error(`Failed to send RPC request: ${error}`));
}
});
}
/**
* Start health check monitoring
*/
private startHealthCheck(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
if (this.status.state !== 'running') {
return;
}
try {
const health = await this.checkHealth();
if (!health.ok) {
console.warn('Gateway health check failed:', health.error);
this.emit('error', new Error(health.error || 'Health check failed'));
}
} catch (error) {
console.error('Health check error:', error);
}
}, 30000); // Check every 30 seconds
}
/**
* Check Gateway health via WebSocket ping
* OpenClaw Gateway doesn't have an HTTP /health endpoint
*/
async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> {
try {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const uptime = this.status.connectedAt
? Math.floor((Date.now() - this.status.connectedAt) / 1000)
: undefined;
return { ok: true, uptime };
}
return { ok: false, error: 'WebSocket not connected' };
} catch (error) {
return { ok: false, error: String(error) };
}
}
/**
* Find existing Gateway process by attempting a WebSocket connection
*/
private async findExistingGateway(): Promise<{ port: number } | null> {
try {
const port = PORTS.OPENCLAW_GATEWAY;
// Try a quick WebSocket connection to check if gateway is listening
return await new Promise<{ port: number } | null>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(null);
}, 2000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve({ port });
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(null);
});
});
} catch {
// Gateway not running
}
return null;
}
/**
* Start Gateway process
* Uses OpenClaw submodule - supports both production (dist) and development modes
*/
private async startProcess(): Promise<void> {
const openclawDir = getOpenClawDir();
const entryScript = getOpenClawEntryPath();
// Verify OpenClaw submodule exists
if (!isOpenClawSubmodulePresent()) {
throw new Error(
'OpenClaw submodule not found. Please run: git submodule update --init'
);
}
// Verify dependencies are installed
if (!isOpenClawInstalled()) {
throw new Error(
'OpenClaw dependencies not installed. Please run: cd openclaw && pnpm install'
);
}
// Get or generate gateway token
const gatewayToken = await getSetting('gatewayToken');
console.log('Using gateway token:', gatewayToken.substring(0, 10) + '...');
let command: string;
let args: string[];
// Check if OpenClaw is built (production mode) or use pnpm dev mode
if (isOpenClawBuilt() && existsSync(entryScript)) {
// Production mode: use openclaw.mjs directly
console.log('Starting Gateway in production mode (using dist)');
command = 'node';
args = [entryScript, 'gateway', 'run', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
} else {
// Development mode: use pnpm gateway:dev which handles tsx compilation
console.log('Starting Gateway in development mode (using pnpm)');
command = 'pnpm';
args = ['run', 'dev', 'gateway', 'run', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured'];
}
console.log(`Spawning Gateway: ${command} ${args.join(' ')}`);
console.log(`Working directory: ${openclawDir}`);
// Load provider API keys from secure storage to pass as environment variables
const providerEnv: Record<string, string> = {};
const providerTypes = ['anthropic', 'openai', 'google', 'openrouter'];
for (const providerType of providerTypes) {
try {
const key = await getApiKey(providerType);
if (key) {
const envVar = getProviderEnvVar(providerType);
if (envVar) {
providerEnv[envVar] = key;
console.log(`Loaded API key for ${providerType} -> ${envVar}`);
}
}
} catch (err) {
console.warn(`Failed to load API key for ${providerType}:`, err);
}
}
return new Promise((resolve, reject) => {
this.process = spawn(command, args, {
cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
shell: process.platform === 'win32', // Use shell on Windows for pnpm
env: {
...process.env,
// Provider API keys
...providerEnv,
// Skip channel auto-connect during startup for faster boot
OPENCLAW_SKIP_CHANNELS: '1',
CLAWDBOT_SKIP_CHANNELS: '1',
// Also set token via environment variable as fallback
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
},
});
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 (filter out noisy control-ui token_mismatch messages)
this.process.stderr?.on('data', (data) => {
const msg = data.toString();
// Suppress the constant Control UI token_mismatch noise
// These come from the browser-based Control UI auto-polling with no token
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
return;
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
return;
}
console.error('Gateway error:', msg);
});
// Store PID
if (this.process.pid) {
this.setStatus({ pid: this.process.pid });
}
resolve();
});
}
/**
* Wait for Gateway to be ready by checking if the port is accepting connections
*/
private async waitForReady(retries = 30, interval = 1000): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
// Try a quick WebSocket connection to see if the gateway is listening
const ready = await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`);
const timeout = setTimeout(() => {
testWs.close();
resolve(false);
}, 1000);
testWs.on('open', () => {
clearTimeout(timeout);
testWs.close();
resolve(true);
});
testWs.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
if (ready) {
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> {
// Get token for WebSocket authentication
const gatewayToken = await getSetting('gatewayToken');
return new Promise((resolve, reject) => {
// WebSocket URL (token will be sent in connect handshake, not URL)
const wsUrl = `ws://localhost:${port}/ws`;
this.ws = new WebSocket(wsUrl);
let handshakeComplete = false;
this.ws.on('open', async () => {
console.log('WebSocket opened, sending connect handshake...');
// Send proper connect handshake as required by OpenClaw Gateway protocol
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
const connectId = `connect-${Date.now()}`;
const connectFrame = {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
displayName: 'ClawX',
version: '0.1.0',
platform: process.platform,
mode: 'ui',
},
auth: {
token: gatewayToken,
},
caps: [],
role: 'operator',
scopes: [],
},
};
console.log('Sending connect handshake:', JSON.stringify(connectFrame));
this.ws?.send(JSON.stringify(connectFrame));
// Store pending connect request
const connectTimeout = setTimeout(() => {
if (!handshakeComplete) {
console.error('Connect handshake timeout');
reject(new Error('Connect handshake timeout'));
this.ws?.close();
}
}, 10000);
this.pendingRequests.set(connectId, {
resolve: (_result) => {
clearTimeout(connectTimeout);
handshakeComplete = true;
console.log('WebSocket handshake complete, gateway connected');
this.setStatus({
state: 'running',
port,
connectedAt: Date.now(),
});
this.startPing();
resolve();
},
reject: (error) => {
clearTimeout(connectTimeout);
console.error('Connect handshake failed:', error);
reject(error);
},
timeout: connectTimeout,
});
});
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', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown';
console.log(`WebSocket disconnected: code=${code}, reason=${reasonStr}`);
if (!handshakeComplete) {
reject(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return;
}
if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' });
this.scheduleReconnect();
}
});
this.ws.on('error', (error) => {
console.error('WebSocket error:', error);
if (!handshakeComplete) {
reject(error);
}
});
});
}
/**
* Handle incoming WebSocket message
*/
private handleMessage(message: unknown): void {
if (typeof message !== 'object' || message === null) {
console.warn('Received non-object message:', message);
return;
}
const msg = message as Record<string, unknown>;
// Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... }
if (msg.type === 'res' && typeof msg.id === 'string') {
if (this.pendingRequests.has(msg.id)) {
const request = this.pendingRequests.get(msg.id)!;
clearTimeout(request.timeout);
this.pendingRequests.delete(msg.id);
if (msg.ok === false || msg.error) {
const errorObj = msg.error as { message?: string; code?: number } | undefined;
const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error';
request.reject(new Error(errorMsg));
} else {
request.resolve(msg.payload ?? msg);
}
return;
}
}
// Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} }
if (msg.type === 'event' && typeof msg.event === 'string') {
this.handleProtocolEvent(msg.event, msg.payload);
return;
}
// Fallback: Check if this is a JSON-RPC 2.0 response (legacy support)
if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) {
const request = this.pendingRequests.get(String(message.id))!;
clearTimeout(request.timeout);
this.pendingRequests.delete(String(message.id));
if (message.error) {
const errorMsg = typeof message.error === 'object'
? (message.error as { message?: string }).message || JSON.stringify(message.error)
: String(message.error);
request.reject(new Error(errorMsg));
} else {
request.resolve(message.result);
}
return;
}
// Check if this is a JSON-RPC notification (server-initiated event)
if (isNotification(message)) {
this.handleNotification(message);
return;
}
// Emit generic message for other handlers
this.emit('message', message);
}
/**
* Handle OpenClaw protocol events
*/
private handleProtocolEvent(event: string, payload: unknown): void {
// Map OpenClaw events to our internal event types
switch (event) {
case 'tick':
// Heartbeat tick, ignore
break;
case 'chat':
this.emit('chat:message', { message: payload });
break;
case 'channel.status':
this.emit('channel:status', payload as { channelId: string; status: string });
break;
default:
// Forward unknown events as generic notifications
this.emit('notification', { method: event, params: payload });
}
}
/**
* Handle server-initiated notifications
*/
private handleNotification(notification: JsonRpcNotification): void {
this.emit('notification', notification);
// Route specific events
switch (notification.method) {
case GatewayEventType.CHANNEL_STATUS_CHANGED:
this.emit('channel:status', notification.params as { channelId: string; status: string });
break;
case GatewayEventType.MESSAGE_RECEIVED:
this.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
this.emit('error', new Error(errorData.message || 'Gateway error'));
break;
}
default:
// Unknown notification type, just log it
console.log('Unknown Gateway notification:', notification.method);
}
}
/**
* 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 with exponential backoff
*/
private scheduleReconnect(): void {
if (!this.shouldReconnect) {
console.log('Auto-reconnect disabled, not scheduling reconnect');
return;
}
if (this.reconnectTimer) {
return;
}
if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
console.error(`Max reconnection attempts (${this.reconnectConfig.maxAttempts}) reached`);
this.setStatus({
state: 'error',
error: 'Failed to reconnect after maximum attempts',
reconnectAttempts: this.reconnectAttempts
});
return;
}
// Calculate delay with exponential backoff
const delay = Math.min(
this.reconnectConfig.baseDelay * Math.pow(2, this.reconnectAttempts),
this.reconnectConfig.maxDelay
);
this.reconnectAttempts++;
console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
this.setStatus({
state: 'reconnecting',
reconnectAttempts: this.reconnectAttempts
});
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
// Try to find existing Gateway first
const existing = await this.findExistingGateway();
if (existing) {
await this.connect(existing.port);
this.reconnectAttempts = 0;
this.startHealthCheck();
return;
}
// Otherwise restart the process
await this.startProcess();
await this.waitForReady();
await this.connect(this.status.port);
this.reconnectAttempts = 0;
this.startHealthCheck();
} catch (error) {
console.error('Reconnection failed:', error);
this.scheduleReconnect();
}
}, delay);
}
/**
* Update status and emit event
*/
private setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state;
this.status = { ...this.status, ...update };
// Calculate uptime if connected
if (this.status.state === 'running' && this.status.connectedAt) {
this.status.uptime = Date.now() - this.status.connectedAt;
}
this.emit('status', this.status);
// Log state transitions
if (previousState !== this.status.state) {
console.log(`Gateway state: ${previousState} -> ${this.status.state}`);
}
}
}

View 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)
);
}

161
electron/main/index.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Electron Main Process Entry
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, session, 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 { appUpdater, registerUpdateHandlers } from './updater';
// 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,
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
},
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);
// Override security headers ONLY for the OpenClaw Gateway Control UI
// The Control UI sets X-Frame-Options: DENY and CSP frame-ancestors 'none'
// which prevents embedding in an iframe. Only apply to gateway URLs.
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
if (!isGatewayUrl) {
callback({ responseHeaders: details.responseHeaders });
return;
}
const headers = { ...details.responseHeaders };
// Remove X-Frame-Options to allow embedding in iframe
delete headers['X-Frame-Options'];
delete headers['x-frame-options'];
// Remove restrictive CSP frame-ancestors
if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
callback({ responseHeaders: headers });
});
// Register IPC handlers
registerIpcHandlers(gatewayManager, mainWindow);
// Register update handlers
registerUpdateHandlers(appUpdater, mainWindow);
// Check for updates after a delay (only in production)
if (!process.env.VITE_DEV_SERVER_URL) {
setTimeout(() => {
appUpdater.checkForUpdates().catch((err) => {
console.error('Failed to check for updates:', err);
});
}, 10000); // Check after 10 seconds
}
// 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 };

View File

@@ -0,0 +1,707 @@
/**
* IPC Handlers
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
import { GatewayManager } from '../gateway/manager';
import {
storeApiKey,
getApiKey,
deleteApiKey,
hasApiKey,
saveProvider,
getProvider,
deleteProvider,
setDefaultProvider,
getDefaultProvider,
getAllProvidersWithKeyInfo,
isEncryptionAvailable,
type ProviderConfig,
} from '../utils/secure-storage';
import { getOpenClawStatus } from '../utils/paths';
import { getSetting } from '../utils/store';
import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth';
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(
gatewayManager: GatewayManager,
mainWindow: BrowserWindow
): void {
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);
// OpenClaw handlers
registerOpenClawHandlers();
// Provider handlers
registerProviderHandlers();
// 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();
});
// Check if Gateway is connected
ipcMain.handle('gateway:isConnected', () => {
return gatewayManager.isConnected();
});
// 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.restart();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Gateway RPC call
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
try {
const result = await gatewayManager.rpc(method, params, timeoutMs);
return { success: true, result };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Get the Control UI URL with token for embedding
ipcMain.handle('gateway:getControlUiUrl', async () => {
try {
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || 18789;
// Pass token as query param - Control UI will store it in localStorage
const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
return { success: true, url, port, token };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Health check
ipcMain.handle('gateway:health', async () => {
try {
const health = await gatewayManager.checkHealth();
return { success: true, ...health };
} catch (error) {
return { success: false, ok: false, error: String(error) };
}
});
// Forward Gateway events to renderer
gatewayManager.on('status', (status) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:status-changed', status);
}
});
gatewayManager.on('message', (message) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:message', message);
}
});
gatewayManager.on('notification', (notification) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:notification', notification);
}
});
gatewayManager.on('channel:status', (data) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:channel-status', data);
}
});
gatewayManager.on('chat:message', (data) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:chat-message', data);
}
});
gatewayManager.on('exit', (code) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:exit', code);
}
});
gatewayManager.on('error', (error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:error', error.message);
}
});
}
/**
* OpenClaw-related IPC handlers
* For checking submodule status
*/
function registerOpenClawHandlers(): void {
// Get OpenClaw submodule status
ipcMain.handle('openclaw:status', () => {
return getOpenClawStatus();
});
// Check if OpenClaw is ready (submodule present and dependencies installed)
ipcMain.handle('openclaw:isReady', () => {
const status = getOpenClawStatus();
return status.submoduleExists && status.isInstalled;
});
}
/**
* Provider-related IPC handlers
*/
function registerProviderHandlers(): void {
// Check if encryption is available
ipcMain.handle('provider:encryptionAvailable', () => {
return isEncryptionAvailable();
});
// Get all providers with key info
ipcMain.handle('provider:list', async () => {
return await getAllProvidersWithKeyInfo();
});
// Get a specific provider
ipcMain.handle('provider:get', async (_, providerId: string) => {
return await getProvider(providerId);
});
// Save a provider configuration
ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => {
try {
// Save the provider config
await saveProvider(config);
// Store the API key if provided
if (apiKey) {
await storeApiKey(config.id, apiKey);
// Also write to OpenClaw auth-profiles.json so the gateway can use it
try {
saveProviderKeyToOpenClaw(config.type, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
}
// Set the default model in OpenClaw config based on provider type
try {
setOpenClawDefaultModel(config.type);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Delete a provider
ipcMain.handle('provider:delete', async (_, providerId: string) => {
try {
await deleteProvider(providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Update API key for a provider
ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => {
try {
await storeApiKey(providerId, apiKey);
// Also write to OpenClaw auth-profiles.json
// Resolve provider type from stored config, or use providerId as type
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
try {
saveProviderKeyToOpenClaw(providerType, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Delete API key for a provider
ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => {
try {
await deleteApiKey(providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Check if a provider has an API key
ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => {
return await hasApiKey(providerId);
});
// Get the actual API key (for internal use only - be careful!)
ipcMain.handle('provider:getApiKey', async (_, providerId: string) => {
return await getApiKey(providerId);
});
// Set default provider
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
try {
await setDefaultProvider(providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Get default provider
ipcMain.handle('provider:getDefault', async () => {
return await getDefaultProvider();
});
// Validate API key by making a real test request to the provider
// providerId can be either a stored provider ID or a provider type (e.g., 'openrouter', 'anthropic')
ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => {
try {
// First try to get existing provider
const provider = await getProvider(providerId);
// Use provider.type if provider exists, otherwise use providerId as the type
// This allows validation during setup when provider hasn't been saved yet
const providerType = provider?.type || providerId;
console.log(`Validating API key for provider type: ${providerType}`);
return await validateApiKeyWithProvider(providerType, apiKey);
} catch (error) {
console.error('Validation error:', error);
return { valid: false, error: String(error) };
}
});
}
/**
* Validate API key by making a real chat completion API call to the provider
* This sends a minimal "hi" message to verify the key works
*/
async function validateApiKeyWithProvider(
providerType: string,
apiKey: string
): Promise<{ valid: boolean; error?: string }> {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (providerType) {
case 'anthropic':
return await validateAnthropicKey(trimmedKey);
case 'openai':
return await validateOpenAIKey(trimmedKey);
case 'google':
return await validateGoogleKey(trimmedKey);
case 'openrouter':
return await validateOpenRouterKey(trimmedKey);
case 'ollama':
// Ollama doesn't require API key validation
return { valid: true };
default:
// For custom providers, just check the key is not empty
return { valid: true };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}
/**
* Parse error message from API response
*/
function parseApiError(data: unknown): string {
if (!data || typeof data !== 'object') return 'Unknown error';
// Anthropic format: { error: { message: "..." } }
// OpenAI format: { error: { message: "..." } }
// Google format: { error: { message: "..." } }
const obj = data as { error?: { message?: string; type?: string }; message?: string };
if (obj.error?.message) return obj.error.message;
if (obj.message) return obj.message;
return 'Unknown error';
}
/**
* Validate Anthropic API key by making a minimal chat completion request
*/
async function validateAnthropicKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
return { valid: true };
}
// Authentication error
if (response.status === 401) {
return { valid: false, error: 'Invalid API key' };
}
// Permission error (invalid key format, etc.)
if (response.status === 403) {
return { valid: false, error: parseApiError(data) };
}
// Rate limit or overloaded - key is valid but service is busy
if (response.status === 429 || response.status === 529) {
return { valid: true };
}
// Model not found or bad request but auth passed - key is valid
if (response.status === 400 || response.status === 404) {
const errorType = (data as { error?: { type?: string } })?.error?.type;
if (errorType === 'authentication_error' || errorType === 'invalid_api_key') {
return { valid: false, error: 'Invalid API key' };
}
// Other errors like invalid_request_error mean the key is valid
return { valid: true };
}
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
}
/**
* Validate OpenAI API key by making a minimal chat completion request
*/
async function validateOpenAIKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
return { valid: true };
}
// Authentication error
if (response.status === 401) {
return { valid: false, error: 'Invalid API key' };
}
// Rate limit - key is valid
if (response.status === 429) {
return { valid: true };
}
// Model not found or bad request but auth passed - key is valid
if (response.status === 400 || response.status === 404) {
const errorCode = (data as { error?: { code?: string } })?.error?.code;
if (errorCode === 'invalid_api_key') {
return { valid: false, error: 'Invalid API key' };
}
return { valid: true };
}
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
}
/**
* Validate Google (Gemini) API key by making a minimal generate content request
*/
async function validateGoogleKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
contents: [{ parts: [{ text: 'hi' }] }],
generationConfig: { maxOutputTokens: 1 },
}),
}
);
const data = await response.json().catch(() => ({}));
if (response.ok) {
return { valid: true };
}
// Authentication error
if (response.status === 400 || response.status === 401 || response.status === 403) {
const errorStatus = (data as { error?: { status?: string } })?.error?.status;
if (errorStatus === 'UNAUTHENTICATED' || errorStatus === 'PERMISSION_DENIED') {
return { valid: false, error: 'Invalid API key' };
}
// Check if it's actually an auth error
const errorMessage = parseApiError(data).toLowerCase();
if (errorMessage.includes('api key') || errorMessage.includes('invalid') || errorMessage.includes('unauthorized')) {
return { valid: false, error: parseApiError(data) };
}
// Other errors mean key is valid
return { valid: true };
}
// Rate limit - key is valid
if (response.status === 429) {
return { valid: true };
}
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
}
/**
* Validate OpenRouter API key by making a minimal chat completion request
*/
async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; error?: string }> {
try {
// Use a popular free model for validation
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'https://clawx.app',
'X-Title': 'ClawX',
},
body: JSON.stringify({
model: 'meta-llama/llama-3.2-3b-instruct:free',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
const data = await response.json().catch(() => ({}));
console.log('OpenRouter validation response:', response.status, JSON.stringify(data));
// Helper to check if error message indicates auth failure
const isAuthError = (d: unknown): boolean => {
const errorObj = (d as { error?: { message?: string; code?: number | string; type?: string } })?.error;
if (!errorObj) return false;
const message = (errorObj.message || '').toLowerCase();
const code = errorObj.code;
const type = (errorObj.type || '').toLowerCase();
// Check for explicit auth-related errors
if (code === 401 || code === '401' || code === 403 || code === '403') return true;
if (type.includes('auth') || type.includes('invalid')) return true;
if (message.includes('invalid api key') || message.includes('invalid key') ||
message.includes('unauthorized') || message.includes('authentication') ||
message.includes('invalid credentials') || message.includes('api key is not valid')) {
return true;
}
return false;
};
if (response.ok) {
return { valid: true };
}
// Always check for auth errors in the response body first
if (isAuthError(data)) {
// Return user-friendly message instead of raw API errors like "User not found."
return { valid: false, error: 'Invalid API key' };
}
// Authentication error status codes - always return user-friendly message
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
// Rate limit - key is valid
if (response.status === 429) {
return { valid: true };
}
// Payment required or insufficient credits - key format is valid
if (response.status === 402) {
return { valid: true };
}
// For 400/404, we must be very careful - only consider valid if clearly not an auth issue
if (response.status === 400 || response.status === 404) {
// If we got here without detecting auth error, it might be a model issue
// But be conservative - require explicit success indication
const errorObj = (data as { error?: { message?: string; code?: number } })?.error;
const message = (errorObj?.message || '').toLowerCase();
// Only consider valid if the error is clearly about the model, not the key
if (message.includes('model') && !message.includes('key') && !message.includes('auth')) {
return { valid: true };
}
// Default to invalid for ambiguous 400/404 errors
return { valid: false, error: parseApiError(data) || 'Invalid API key or request' };
}
return { valid: false, error: parseApiError(data) || `API error: ${response.status}` };
} catch (error) {
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
}
}
/**
* 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
View 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/ValueCell-ai/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
View 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;
}
}

264
electron/main/updater.ts Normal file
View File

@@ -0,0 +1,264 @@
/**
* Auto-Updater Module
* Handles automatic application updates using electron-updater
*/
import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater';
import { BrowserWindow, app, ipcMain } from 'electron';
import { EventEmitter } from 'events';
export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
}
export interface UpdaterEvents {
'status-changed': (status: UpdateStatus) => void;
'checking-for-update': () => void;
'update-available': (info: UpdateInfo) => void;
'update-not-available': (info: UpdateInfo) => void;
'download-progress': (progress: ProgressInfo) => void;
'update-downloaded': (event: UpdateDownloadedEvent) => void;
'error': (error: Error) => void;
}
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
constructor() {
super();
// Configure auto-updater
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Use logger
autoUpdater.logger = {
info: (msg: string) => console.log('[Updater]', msg),
warn: (msg: string) => console.warn('[Updater]', msg),
error: (msg: string) => console.error('[Updater]', msg),
debug: (msg: string) => console.debug('[Updater]', msg),
};
this.setupListeners();
}
/**
* Set the main window for sending update events
*/
setMainWindow(window: BrowserWindow): void {
this.mainWindow = window;
}
/**
* Get current update status
*/
getStatus(): UpdateStatus {
return this.status;
}
/**
* Setup auto-updater event listeners
*/
private setupListeners(): void {
autoUpdater.on('checking-for-update', () => {
this.updateStatus({ status: 'checking' });
this.emit('checking-for-update');
});
autoUpdater.on('update-available', (info: UpdateInfo) => {
this.updateStatus({ status: 'available', info });
this.emit('update-available', info);
});
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
this.updateStatus({ status: 'not-available', info });
this.emit('update-not-available', info);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
this.updateStatus({ status: 'downloading', progress });
this.emit('download-progress', progress);
});
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
this.updateStatus({ status: 'downloaded', info: event });
this.emit('update-downloaded', event);
});
autoUpdater.on('error', (error: Error) => {
this.updateStatus({ status: 'error', error: error.message });
this.emit('error', error);
});
}
/**
* Update status and notify renderer
*/
private updateStatus(newStatus: Partial<UpdateStatus>): void {
this.status = { ...this.status, ...newStatus };
this.sendToRenderer('update:status-changed', this.status);
}
/**
* Send event to renderer process
*/
private sendToRenderer(channel: string, data: unknown): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, data);
}
}
/**
* Check for updates
*/
async checkForUpdates(): Promise<UpdateInfo | null> {
try {
const result = await autoUpdater.checkForUpdates();
return result?.updateInfo || null;
} catch (error) {
console.error('[Updater] Check for updates failed:', error);
return null;
}
}
/**
* Download available update
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate();
} catch (error) {
console.error('[Updater] Download update failed:', error);
throw error;
}
}
/**
* Install update and restart app
*/
quitAndInstall(): void {
autoUpdater.quitAndInstall();
}
/**
* Set update channel (stable, beta, dev)
*/
setChannel(channel: 'stable' | 'beta' | 'dev'): void {
autoUpdater.channel = channel;
}
/**
* Set auto-download preference
*/
setAutoDownload(enable: boolean): void {
autoUpdater.autoDownload = enable;
}
/**
* Get current version
*/
getCurrentVersion(): string {
return app.getVersion();
}
}
/**
* Register IPC handlers for update operations
*/
export function registerUpdateHandlers(
updater: AppUpdater,
mainWindow: BrowserWindow
): void {
updater.setMainWindow(mainWindow);
// Get current update status
ipcMain.handle('update:status', () => {
return updater.getStatus();
});
// Get current version
ipcMain.handle('update:version', () => {
return updater.getCurrentVersion();
});
// Check for updates
ipcMain.handle('update:check', async () => {
try {
const info = await updater.checkForUpdates();
return { success: true, info };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Download update
ipcMain.handle('update:download', async () => {
try {
await updater.downloadUpdate();
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
});
// Install update and restart
ipcMain.handle('update:install', () => {
updater.quitAndInstall();
return { success: true };
});
// Set update channel
ipcMain.handle('update:setChannel', (_, channel: 'stable' | 'beta' | 'dev') => {
updater.setChannel(channel);
return { success: true };
});
// Set auto-download preference
ipcMain.handle('update:setAutoDownload', (_, enable: boolean) => {
updater.setAutoDownload(enable);
return { success: true };
});
// Forward update events to renderer
updater.on('checking-for-update', () => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:checking');
}
});
updater.on('update-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:available', info);
}
});
updater.on('update-not-available', (info) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:not-available', info);
}
});
updater.on('download-progress', (progress) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:progress', progress);
}
});
updater.on('update-downloaded', (event) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:downloaded', event);
}
});
updater.on('error', (error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('update:error', error.message);
}
});
}
// Export singleton instance
export const appUpdater = new AppUpdater();

96
electron/main/window.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Window Management Utilities
* Handles window state persistence and multi-window management
*/
import { BrowserWindow, screen } from 'electron';
interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let windowStateStore: any = null;
async function getStore() {
if (!windowStateStore) {
const Store = (await import('electron-store')).default;
windowStateStore = new Store<{ windowState: WindowState }>({
name: 'window-state',
defaults: {
windowState: {
width: 1280,
height: 800,
isMaximized: false,
},
},
});
}
return windowStateStore;
}
/**
* Get saved window state with bounds validation
*/
export async function getWindowState(): Promise<WindowState> {
const store = await getStore();
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 async function saveWindowState(win: BrowserWindow): Promise<void> {
const store = await getStore();
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) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
win.on(event as any, () => saveWindowState(win));
});
}

194
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,194 @@
/**
* 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:isConnected',
'gateway:start',
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
'openclaw:status',
'openclaw:isReady',
// 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:status',
'update:version',
'update:check',
'update:download',
'update:install',
'update:setChannel',
'update:setAutoDownload',
// Env
'env:getConfig',
'env:setApiKey',
'env:deleteApiKey',
// Provider
'provider:encryptionAvailable',
'provider:list',
'provider:get',
'provider:save',
'provider:delete',
'provider:setApiKey',
'provider:deleteApiKey',
'provider:hasApiKey',
'provider:getApiKey',
'provider:setDefault',
'provider:getDefault',
'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:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
'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:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
'update:available',
'update:not-available',
'update:progress',
'update:downloaded',
'update:error',
];
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) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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
View 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
View 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 {
// 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,
};

View File

@@ -0,0 +1,269 @@
/**
* OpenClaw Auth Profiles Utility
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
* so the OpenClaw Gateway can load them for AI provider calls.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
/**
* Auth profile entry for an API key
*/
interface AuthProfileEntry {
type: 'api_key';
provider: string;
key: string;
}
/**
* Auth profiles store format
*/
interface AuthProfilesStore {
version: number;
profiles: Record<string, AuthProfileEntry>;
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
}
/**
* Provider type to environment variable name mapping
*/
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
google: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
groq: 'GROQ_API_KEY',
deepgram: 'DEEPGRAM_API_KEY',
cerebras: 'CEREBRAS_API_KEY',
xai: 'XAI_API_KEY',
mistral: 'MISTRAL_API_KEY',
};
/**
* Get the path to the auth-profiles.json for a given agent
*/
function getAuthProfilesPath(agentId = 'main'): string {
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
}
/**
* Read existing auth profiles store, or create an empty one
*/
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
const filePath = getAuthProfilesPath(agentId);
try {
if (existsSync(filePath)) {
const raw = readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as AuthProfilesStore;
// Validate basic structure
if (data.version && data.profiles && typeof data.profiles === 'object') {
return data;
}
}
} catch (error) {
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
}
return {
version: AUTH_STORE_VERSION,
profiles: {},
};
}
/**
* Write auth profiles store to disk
*/
function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void {
const filePath = getAuthProfilesPath(agentId);
const dir = join(filePath, '..');
// Ensure directory exists
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8');
}
/**
* Save a provider API key to OpenClaw's auth-profiles.json
* This writes the key in the format OpenClaw expects so the gateway
* can use it for AI provider calls.
*
* @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google')
* @param apiKey - The API key to store
* @param agentId - Agent ID (defaults to 'main')
*/
export function saveProviderKeyToOpenClaw(
provider: string,
apiKey: string,
agentId = 'main'
): void {
const store = readAuthProfiles(agentId);
// Profile ID follows OpenClaw convention: <provider>:default
const profileId = `${provider}:default`;
// Upsert the profile entry
store.profiles[profileId] = {
type: 'api_key',
provider,
key: apiKey,
};
// Update order to include this profile
if (!store.order) {
store.order = {};
}
if (!store.order[provider]) {
store.order[provider] = [];
}
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}
// Set as last good
if (!store.lastGood) {
store.lastGood = {};
}
store.lastGood[provider] = profileId;
writeAuthProfiles(store, agentId);
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agent: ${agentId})`);
}
/**
* Get the environment variable name for a provider type
*/
export function getProviderEnvVar(provider: string): string | undefined {
return PROVIDER_ENV_VARS[provider];
}
/**
* Build environment variables object with all stored API keys
* for passing to the Gateway process
*/
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
const env: Record<string, string> = {};
for (const { type, apiKey } of providers) {
const envVar = PROVIDER_ENV_VARS[type];
if (envVar && apiKey) {
env[envVar] = apiKey;
}
}
return env;
}
/**
* Provider type to default model mapping
* Used to set the gateway's default model when the user selects a provider
*/
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
anthropic: 'anthropic/claude-opus-4-6',
openai: 'openai/gpt-5.2',
google: 'google/gemini-3-pro-preview',
openrouter: 'openrouter/anthropic/claude-opus-4.6',
};
/**
* Provider configurations needed for model resolution.
* OpenClaw resolves models by checking cfg.models.providers[provider].
* Without this, any model for the provider returns "Unknown model".
*/
const PROVIDER_CONFIGS: Record<string, { baseUrl: string; api: string; apiKeyEnv: string }> = {
openrouter: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openai-completions',
apiKeyEnv: 'OPENROUTER_API_KEY',
},
openai: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
apiKeyEnv: 'OPENAI_API_KEY',
},
google: {
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
api: 'google',
apiKeyEnv: 'GEMINI_API_KEY',
},
// anthropic is built-in to OpenClaw's model registry, no provider config needed
};
/**
* Update the OpenClaw config to use the given provider and model
* Writes to ~/.openclaw/openclaw.json
*/
export function setOpenClawDefaultModel(provider: string): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch (err) {
console.warn('Failed to read openclaw.json, creating fresh config:', err);
}
const model = PROVIDER_DEFAULT_MODELS[provider];
if (!model) {
console.warn(`No default model mapping for provider "${provider}"`);
return;
}
// Set the default model for the agents
// model must be an object: { primary: "provider/model", fallbacks?: [] }
const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
defaults.model = { primary: model };
agents.defaults = defaults;
config.agents = agents;
// Configure models.providers for providers that need explicit registration
// Without this, OpenClaw returns "Unknown model" because it can't resolve
// the provider's baseUrl and API type
const providerCfg = PROVIDER_CONFIGS[provider];
if (providerCfg) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
// Only set if not already configured
if (!providers[provider]) {
providers[provider] = {
baseUrl: providerCfg.baseUrl,
api: providerCfg.api,
apiKey: providerCfg.apiKeyEnv,
models: [],
};
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}`);
}
models.providers = providers;
config.models = models;
}
// Ensure gateway mode is set
const gateway = (config.gateway || {}) as Record<string, unknown>;
if (!gateway.mode) {
gateway.mode = 'local';
}
config.gateway = gateway;
// Ensure directory exists
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
}

132
electron/utils/paths.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* 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');
}
/**
* Get OpenClaw submodule directory
*/
export function getOpenClawDir(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'openclaw');
}
return join(__dirname, '../../openclaw');
}
/**
* Get OpenClaw entry script path (openclaw.mjs)
*/
export function getOpenClawEntryPath(): string {
return join(getOpenClawDir(), 'openclaw.mjs');
}
/**
* Check if OpenClaw submodule exists
*/
export function isOpenClawSubmodulePresent(): boolean {
return existsSync(getOpenClawDir()) && existsSync(join(getOpenClawDir(), 'package.json'));
}
/**
* Check if OpenClaw is built (has dist folder with entry.js)
*/
export function isOpenClawBuilt(): boolean {
return existsSync(join(getOpenClawDir(), 'dist', 'entry.js'));
}
/**
* Check if OpenClaw has node_modules installed
*/
export function isOpenClawInstalled(): boolean {
return existsSync(join(getOpenClawDir(), 'node_modules'));
}
/**
* Get OpenClaw status for environment check
*/
export interface OpenClawStatus {
submoduleExists: boolean;
isInstalled: boolean;
isBuilt: boolean;
entryPath: string;
dir: string;
}
export function getOpenClawStatus(): OpenClawStatus {
const dir = getOpenClawDir();
return {
submoduleExists: isOpenClawSubmodulePresent(),
isInstalled: isOpenClawInstalled(),
isBuilt: isOpenClawBuilt(),
entryPath: getOpenClawEntryPath(),
dir,
};
}

View File

@@ -0,0 +1,275 @@
/**
* Secure Storage Utility
* Uses Electron's safeStorage for encrypting sensitive data like API keys
*/
import { safeStorage } from 'electron';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let store: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
async function getStore() {
if (!store) {
const Store = (await import('electron-store')).default;
store = new Store({
name: 'clawx-secure',
defaults: {
encryptedKeys: {},
},
});
}
return store;
}
async function getProviderStore() {
if (!providerStore) {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
defaults: {
providers: {},
},
});
}
return providerStore;
}
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom';
baseUrl?: string;
model?: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Check if encryption is available
*/
export function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable();
}
/**
* Store an API key securely
*/
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
try {
const s = await getStore();
if (!safeStorage.isEncryptionAvailable()) {
console.warn('Encryption not available, storing key in plain text');
// Fallback to plain storage (not recommended for production)
const keys = s.get('encryptedKeys') as Record<string, string>;
keys[providerId] = Buffer.from(apiKey).toString('base64');
s.set('encryptedKeys', keys);
return true;
}
// Encrypt the API key
const encrypted = safeStorage.encryptString(apiKey);
const keys = s.get('encryptedKeys') as Record<string, string>;
keys[providerId] = encrypted.toString('base64');
s.set('encryptedKeys', keys);
return true;
} catch (error) {
console.error('Failed to store API key:', error);
return false;
}
}
/**
* Retrieve an API key
*/
export async function getApiKey(providerId: string): Promise<string | null> {
try {
const s = await getStore();
const keys = s.get('encryptedKeys') as Record<string, string>;
const encryptedBase64 = keys[providerId];
if (!encryptedBase64) {
return null;
}
if (!safeStorage.isEncryptionAvailable()) {
// Fallback for plain storage
return Buffer.from(encryptedBase64, 'base64').toString('utf-8');
}
// Decrypt the API key
const encrypted = Buffer.from(encryptedBase64, 'base64');
return safeStorage.decryptString(encrypted);
} catch (error) {
console.error('Failed to retrieve API key:', error);
return null;
}
}
/**
* Delete an API key
*/
export async function deleteApiKey(providerId: string): Promise<boolean> {
try {
const s = await getStore();
const keys = s.get('encryptedKeys') as Record<string, string>;
delete keys[providerId];
s.set('encryptedKeys', keys);
return true;
} catch (error) {
console.error('Failed to delete API key:', error);
return false;
}
}
/**
* Check if an API key exists for a provider
*/
export async function hasApiKey(providerId: string): Promise<boolean> {
const s = await getStore();
const keys = s.get('encryptedKeys') as Record<string, string>;
return providerId in keys;
}
/**
* List all provider IDs that have stored keys
*/
export async function listStoredKeyIds(): Promise<string[]> {
const s = await getStore();
const keys = s.get('encryptedKeys') as Record<string, string>;
return Object.keys(keys);
}
// ==================== Provider Configuration ====================
/**
* Save a provider configuration
*/
export async function saveProvider(config: ProviderConfig): Promise<void> {
const s = await getProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
providers[config.id] = config;
s.set('providers', providers);
}
/**
* Get a provider configuration
*/
export async function getProvider(providerId: string): Promise<ProviderConfig | null> {
const s = await getProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return providers[providerId] || null;
}
/**
* Get all provider configurations
*/
export async function getAllProviders(): Promise<ProviderConfig[]> {
const s = await getProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return Object.values(providers);
}
/**
* Delete a provider configuration
*/
export async function deleteProvider(providerId: string): Promise<boolean> {
try {
// Delete the API key first
await deleteApiKey(providerId);
// Delete the provider config
const s = await getProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
delete providers[providerId];
s.set('providers', providers);
// Clear default if this was the default
if (s.get('defaultProvider') === providerId) {
s.delete('defaultProvider');
}
return true;
} catch (error) {
console.error('Failed to delete provider:', error);
return false;
}
}
/**
* Set the default provider
*/
export async function setDefaultProvider(providerId: string): Promise<void> {
const s = await getProviderStore();
s.set('defaultProvider', providerId);
}
/**
* Get the default provider
*/
export async function getDefaultProvider(): Promise<string | undefined> {
const s = await getProviderStore();
return s.get('defaultProvider') as string | undefined;
}
/**
* Get provider with masked key info (for UI display)
*/
export async function getProviderWithKeyInfo(providerId: string): Promise<(ProviderConfig & { hasKey: boolean; keyMasked: string | null }) | null> {
const provider = await getProvider(providerId);
if (!provider) return null;
const apiKey = await getApiKey(providerId);
let keyMasked: string | null = null;
if (apiKey) {
// Show first 4 and last 4 characters
if (apiKey.length > 12) {
keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
} else {
keyMasked = '*'.repeat(apiKey.length);
}
}
return {
...provider,
hasKey: !!apiKey,
keyMasked,
};
}
/**
* Get all providers with key info (for UI display)
*/
export async function getAllProvidersWithKeyInfo(): Promise<Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>> {
const providers = await getAllProviders();
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
for (const provider of providers) {
const apiKey = await getApiKey(provider.id);
let keyMasked: string | null = null;
if (apiKey) {
if (apiKey.length > 12) {
keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
} else {
keyMasked = '*'.repeat(apiKey.length);
}
}
results.push({
...provider,
hasKey: !!apiKey,
keyMasked,
});
}
return results;
}

149
electron/utils/store.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Persistent Storage
* Electron-store wrapper for application settings
*/
import { randomBytes } from 'crypto';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let settingsStoreInstance: any = null;
/**
* Generate a random token for gateway authentication
*/
function generateToken(): string {
return `clawx-${randomBytes(16).toString('hex')}`;
}
/**
* Application settings schema
*/
export interface AppSettings {
// General
theme: 'light' | 'dark' | 'system';
language: string;
startMinimized: boolean;
launchAtStartup: boolean;
// Gateway
gatewayAutoStart: boolean;
gatewayPort: number;
gatewayToken: string;
// 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,
gatewayToken: generateToken(),
// Update
updateChannel: 'stable',
autoCheckUpdate: true,
autoDownloadUpdate: false,
skippedVersions: [],
// UI State
sidebarCollapsed: false,
devModeUnlocked: false,
// Presets
selectedBundles: ['productivity', 'developer'],
enabledSkills: [],
disabledSkills: [],
};
/**
* Get the settings store instance (lazy initialization)
*/
async function getSettingsStore() {
if (!settingsStoreInstance) {
const Store = (await import('electron-store')).default;
settingsStoreInstance = new Store<AppSettings>({
name: 'settings',
defaults,
});
}
return settingsStoreInstance;
}
/**
* Get a setting value
*/
export async function getSetting<K extends keyof AppSettings>(key: K): Promise<AppSettings[K]> {
const store = await getSettingsStore();
return store.get(key);
}
/**
* Set a setting value
*/
export async function setSetting<K extends keyof AppSettings>(
key: K,
value: AppSettings[K]
): Promise<void> {
const store = await getSettingsStore();
store.set(key, value);
}
/**
* Get all settings
*/
export async function getAllSettings(): Promise<AppSettings> {
const store = await getSettingsStore();
return store.store;
}
/**
* Reset settings to defaults
*/
export async function resetSettings(): Promise<void> {
const store = await getSettingsStore();
store.clear();
}
/**
* Export settings to JSON
*/
export async function exportSettings(): Promise<string> {
const store = await getSettingsStore();
return JSON.stringify(store.store, null, 2);
}
/**
* Import settings from JSON
*/
export async function importSettings(json: string): Promise<void> {
try {
const settings = JSON.parse(json);
const store = await getSettingsStore();
store.set(settings);
} catch {
throw new Error('Invalid settings JSON');
}
}

33
entitlements.mac.plist Normal file
View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for Electron apps -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow JIT compilation for V8 -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Required for Hardened Runtime -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Network access for WebSocket/HTTP -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Allow spawning child processes (for Gateway) -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- File access for user data -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Downloads folder access -->
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>

43
eslint.config.mjs Normal file
View File

@@ -0,0 +1,43 @@
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default [
{
ignores: ['dist/**', 'dist-electron/**', 'openclaw/**'],
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2020,
...globals.node,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// TypeScript handles these checks natively, disable ESLint duplicates
'no-undef': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
];

13
index.html Normal file
View 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>

1
openclaw Submodule

Submodule openclaw added at 54ddbc4660

106
package.json Normal file
View File

@@ -0,0 +1,106 @@
{
"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": "pnpm run build:vite && pnpm run package",
"build:vite": "vite build",
"build:electron": "tsc -p tsconfig.node.json",
"preview": "vite preview",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"icons": "bash scripts/generate-icons.sh",
"clean": "rm -rf dist dist-electron release",
"package": "electron-builder",
"package:mac": "electron-builder --mac",
"package:mac:universal": "electron-builder --mac --universal",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux",
"package:all": "electron-builder -mwl",
"publish": "electron-builder --publish always",
"publish:mac": "electron-builder --mac --publish always",
"publish:win": "electron-builder --win --publish always",
"publish:linux": "electron-builder --linux --publish always",
"release": "pnpm run build:vite && pnpm run publish",
"postinstall": "git submodule update --init",
"openclaw:init": "git submodule update --init && cd openclaw && pnpm install",
"openclaw:install": "cd openclaw && pnpm install",
"openclaw:build": "cd openclaw && pnpm build",
"openclaw:update": "git submodule update --remote openclaw && cd openclaw && pnpm install"
},
"dependencies": {
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@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/jest-dom": "^6.9.1",
"@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",
"globals": "^17.3.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"
}
}

8450
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
packages:
- '.'
ignoredBuiltDependencies:
- electron
- esbuild

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

6
resources/icons/.gitkeep Normal file
View 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)

68
resources/icons/README.md Normal file
View File

@@ -0,0 +1,68 @@
# ClawX Application Icons
This directory contains the application icons for all supported platforms.
## Required Files
| File | Platform | Description |
|------|----------|-------------|
| `icon.svg` | Source | Vector source for all icons |
| `icon.icns` | macOS | Apple Icon Image format |
| `icon.ico` | Windows | Windows ICO format |
| `icon.png` | All | 512x512 PNG fallback |
| `16x16.png` - `512x512.png` | Linux | PNG set for Linux |
## Generating Icons
### Using the Script
```bash
# Make the script executable
chmod +x scripts/generate-icons.sh
# Run icon generation
./scripts/generate-icons.sh
```
### Prerequisites
**macOS:**
```bash
brew install imagemagick librsvg
```
**Linux:**
```bash
apt install imagemagick librsvg2-bin
```
**Windows:**
Install ImageMagick from https://imagemagick.org/
### Manual Generation
If you prefer to generate icons manually:
1. **macOS (.icns)**
- Create a `.iconset` folder with properly named PNGs
- Run: `iconutil -c icns -o icon.icns ClawX.iconset`
2. **Windows (.ico)**
- Use ImageMagick: `convert icon_16.png icon_32.png icon_64.png icon_128.png icon_256.png icon.ico`
3. **Linux (PNGs)**
- Generate PNGs at: 16, 32, 48, 64, 128, 256, 512 pixels
## Design Guidelines
- **Background**: Gradient from #6366f1 to #8b5cf6 (Indigo to Violet)
- **Corner Radius**: ~20% of width (200px on 1024px canvas)
- **Foreground**: White claw symbol with "X" accent
- **Safe Area**: Keep 10% margin from edges
## Updating the Icon
1. Edit `icon.svg` with your vector editor (Figma, Illustrator, Inkscape)
2. Run `./scripts/generate-icons.sh`
3. Verify generated icons look correct
4. Commit all generated files

35
resources/icons/icon.svg Normal file
View File

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
<linearGradient id="clawGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e0e7ff;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect x="0" y="0" width="1024" height="1024" rx="200" fill="url(#bgGrad)"/>
<!-- Claw shape -->
<g transform="translate(512, 512)" fill="url(#clawGrad)">
<!-- Center circle -->
<circle cx="0" cy="0" r="100"/>
<!-- Claw fingers -->
<path d="M -50 -80 Q -80 -200 -150 -280 Q -180 -320 -150 -350 Q -120 -380 -80 -350 Q -20 -300 40 -150 Z" />
<path d="M 50 -80 Q 80 -200 150 -280 Q 180 -320 150 -350 Q 120 -380 80 -350 Q 20 -300 -40 -150 Z" />
<path d="M -100 20 Q -220 0 -320 -40 Q -370 -60 -390 -20 Q -410 20 -370 50 Q -300 100 -150 80 Z" />
<path d="M 100 20 Q 220 0 320 -40 Q 370 -60 390 -20 Q 410 20 370 50 Q 300 100 150 80 Z" />
<path d="M 0 120 Q 0 250 0 350 Q 0 400 40 420 Q 80 400 80 350 L 80 250 Q 60 150 0 120 Z" />
<path d="M 0 120 Q 0 250 0 350 Q 0 400 -40 420 Q -80 400 -80 350 L -80 250 Q -60 150 0 120 Z" />
</g>
<!-- X mark -->
<g transform="translate(750, 750)" fill="#ffffff" opacity="0.9">
<rect x="-60" y="-15" width="120" height="30" rx="15" transform="rotate(45)"/>
<rect x="-60" y="-15" width="120" height="30" rx="15" transform="rotate(-45)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View 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"
]
}
]
}

111
scripts/generate-icons.sh Normal file
View File

@@ -0,0 +1,111 @@
#!/bin/bash
# Icon Generation Script
# Generates app icons for macOS, Windows, and Linux from SVG source
#
# Prerequisites:
# - macOS: brew install imagemagick librsvg
# - Linux: apt install imagemagick librsvg2-bin
# - Windows: Install ImageMagick
#
# Usage: ./scripts/generate-icons.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ICONS_DIR="$PROJECT_DIR/resources/icons"
SVG_SOURCE="$ICONS_DIR/icon.svg"
echo "🎨 Generating ClawX icons..."
# Check if SVG source exists
if [ ! -f "$SVG_SOURCE" ]; then
echo "❌ SVG source not found: $SVG_SOURCE"
exit 1
fi
# Check for required tools
if ! command -v convert &> /dev/null; then
echo "❌ ImageMagick not found. Please install it:"
echo " macOS: brew install imagemagick"
echo " Linux: apt install imagemagick"
exit 1
fi
if ! command -v rsvg-convert &> /dev/null; then
echo "❌ rsvg-convert not found. Please install it:"
echo " macOS: brew install librsvg"
echo " Linux: apt install librsvg2-bin"
exit 1
fi
# Create temp directory
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
echo "📁 Using temp directory: $TEMP_DIR"
# Generate PNG files at various sizes
SIZES=(16 32 64 128 256 512 1024)
for SIZE in "${SIZES[@]}"; do
echo " Generating ${SIZE}x${SIZE} PNG..."
rsvg-convert -w $SIZE -h $SIZE "$SVG_SOURCE" -o "$TEMP_DIR/icon_${SIZE}.png"
done
# ============ macOS (.icns) ============
echo "🍎 Generating macOS .icns..."
ICONSET_DIR="$TEMP_DIR/ClawX.iconset"
mkdir -p "$ICONSET_DIR"
# macOS iconset requires specific file names
cp "$TEMP_DIR/icon_16.png" "$ICONSET_DIR/icon_16x16.png"
cp "$TEMP_DIR/icon_32.png" "$ICONSET_DIR/icon_16x16@2x.png"
cp "$TEMP_DIR/icon_32.png" "$ICONSET_DIR/icon_32x32.png"
cp "$TEMP_DIR/icon_64.png" "$ICONSET_DIR/icon_32x32@2x.png"
cp "$TEMP_DIR/icon_128.png" "$ICONSET_DIR/icon_128x128.png"
cp "$TEMP_DIR/icon_256.png" "$ICONSET_DIR/icon_128x128@2x.png"
cp "$TEMP_DIR/icon_256.png" "$ICONSET_DIR/icon_256x256.png"
cp "$TEMP_DIR/icon_512.png" "$ICONSET_DIR/icon_256x256@2x.png"
cp "$TEMP_DIR/icon_512.png" "$ICONSET_DIR/icon_512x512.png"
cp "$TEMP_DIR/icon_1024.png" "$ICONSET_DIR/icon_512x512@2x.png"
if command -v iconutil &> /dev/null; then
iconutil -c icns -o "$ICONS_DIR/icon.icns" "$ICONSET_DIR"
echo " ✅ Created icon.icns"
else
echo " ⚠️ iconutil not found (macOS only). Skipping .icns generation."
fi
# ============ Windows (.ico) ============
echo "🪟 Generating Windows .ico..."
# Windows ICO typically includes 16, 32, 48, 64, 128, 256
convert "$TEMP_DIR/icon_16.png" \
"$TEMP_DIR/icon_32.png" \
"$TEMP_DIR/icon_64.png" \
"$TEMP_DIR/icon_128.png" \
"$TEMP_DIR/icon_256.png" \
"$ICONS_DIR/icon.ico"
echo " ✅ Created icon.ico"
# ============ Linux (PNG set) ============
echo "🐧 Generating Linux PNG icons..."
LINUX_SIZES=(16 32 48 64 128 256 512)
for SIZE in "${LINUX_SIZES[@]}"; do
cp "$TEMP_DIR/icon_${SIZE}.png" "$ICONS_DIR/${SIZE}x${SIZE}.png" 2>/dev/null || \
rsvg-convert -w $SIZE -h $SIZE "$SVG_SOURCE" -o "$ICONS_DIR/${SIZE}x${SIZE}.png"
done
echo " ✅ Created Linux PNG icons"
# ============ Copy main icon ============
cp "$TEMP_DIR/icon_512.png" "$ICONS_DIR/icon.png"
echo " ✅ Created icon.png (512x512)"
echo ""
echo "✅ Icon generation complete!"
echo " Generated files in: $ICONS_DIR"
ls -la "$ICONS_DIR"

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Post-installation script for ClawX on Linux
set -e
# Update desktop database
if command -v update-desktop-database &> /dev/null; then
update-desktop-database -q /usr/share/applications || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
# Create symbolic link for CLI access (optional)
if [ -x /opt/ClawX/clawx ]; then
ln -sf /opt/ClawX/clawx /usr/local/bin/clawx 2>/dev/null || true
fi
echo "ClawX has been installed successfully."

View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Post-removal script for ClawX on Linux
set -e
# Remove symbolic link
rm -f /usr/local/bin/clawx 2>/dev/null || true
# Update desktop database
if command -v update-desktop-database &> /dev/null; then
update-desktop-database -q /usr/share/applications || true
fi
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -q /usr/share/icons/hicolor || true
fi
echo "ClawX has been removed."

163
src/App.tsx Normal file
View File

@@ -0,0 +1,163 @@
/**
* Root Application Component
* Handles routing and global providers
*/
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Component, useEffect } from 'react';
import type { ErrorInfo, ReactNode } 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';
/**
* Error Boundary to catch and display React rendering errors
*/
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('React Error Boundary caught error:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '40px',
color: '#f87171',
background: '#0f172a',
minHeight: '100vh',
fontFamily: 'monospace'
}}>
<h1 style={{ fontSize: '24px', marginBottom: '16px' }}>Something went wrong</h1>
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
background: '#1e293b',
padding: '16px',
borderRadius: '8px',
fontSize: '14px'
}}>
{this.state.error?.message}
{'\n\n'}
{this.state.error?.stack}
</pre>
<button
onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload(); }}
style={{
marginTop: '16px',
padding: '8px 16px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
function App() {
const navigate = useNavigate();
const location = useLocation();
const theme = useSettingsStore((state) => state.theme);
const setupComplete = useSettingsStore((state) => state.setupComplete);
const initGateway = useGatewayStore((state) => state.init);
// Initialize Gateway connection on mount
useEffect(() => {
initGateway();
}, [initGateway]);
// Redirect to setup wizard if not complete
useEffect(() => {
if (!setupComplete && !location.pathname.startsWith('/setup')) {
navigate('/setup');
}
}, [setupComplete, location.pathname, navigate]);
// Listen for navigation events from main process
useEffect(() => {
const handleNavigate = (...args: unknown[]) => {
const path = args[0];
if (typeof 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 (
<ErrorBoundary>
<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
/>
</ErrorBoundary>
);
}
export default App;

View 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;
}
}

View 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>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Status Badge Component
* Displays connection/state status with color coding
*/
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
export type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting' | 'reconnecting';
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' },
reconnecting: { label: 'Reconnecting', 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>
);
}

View File

@@ -0,0 +1,32 @@
/**
* Header Component
* Top navigation bar with page title and page-specific controls.
* On the Chat page, shows session selector, refresh, thinking toggle, and new session.
*/
import { useLocation } from 'react-router-dom';
import { ChatToolbar } from '@/pages/Chat/ChatToolbar';
// 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 currentTitle = pageTitles[location.pathname] || 'ClawX';
const isChatPage = location.pathname === '/chat';
return (
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
<h2 className="text-lg font-semibold">{currentTitle}</h2>
{/* Chat-specific controls */}
{isChatPage && <ChatToolbar />}
</header>
);
}

View 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>
);
}

View File

@@ -0,0 +1,166 @@
/**
* Sidebar Component
* Navigation sidebar with menu items
*/
import { NavLink } 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';
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 sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const gatewayStatus = useGatewayStore((state) => state.status);
// 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>
)}
{/* 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>
);
}

View File

@@ -0,0 +1,448 @@
/**
* Providers Settings Component
* Manage AI provider configurations and API keys
*/
import { useState, useEffect } from 'react';
import {
Plus,
Trash2,
Edit,
Eye,
EyeOff,
Check,
X,
Loader2,
Star,
Key,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { useProviderStore, type ProviderWithKeyInfo } from '@/stores/providers';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
// Provider type definitions
const providerTypes = [
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...' },
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...' },
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...' },
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...' },
{ id: 'ollama', name: 'Ollama', icon: '🦙', placeholder: 'Not required' },
{ id: 'custom', name: 'Custom', icon: '⚙️', placeholder: 'API key...' },
];
export function ProvidersSettings() {
const {
providers,
defaultProviderId,
loading,
fetchProviders,
addProvider,
updateProvider,
deleteProvider,
setApiKey,
setDefaultProvider,
validateApiKey,
} = useProviderStore();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
// Fetch providers on mount
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
const handleAddProvider = async (type: string, name: string, apiKey: string) => {
try {
await addProvider({
id: `${type}-${Date.now()}`,
type: type as 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom',
name,
enabled: true,
}, apiKey || undefined);
setShowAddDialog(false);
toast.success('Provider added successfully');
} catch (error) {
toast.error(`Failed to add provider: ${error}`);
}
};
const handleDeleteProvider = async (providerId: string) => {
try {
await deleteProvider(providerId);
toast.success('Provider deleted');
} catch (error) {
toast.error(`Failed to delete provider: ${error}`);
}
};
const handleSetDefault = async (providerId: string) => {
try {
await setDefaultProvider(providerId);
toast.success('Default provider updated');
} catch (error) {
toast.error(`Failed to set default: ${error}`);
}
};
const handleToggleEnabled = async (provider: ProviderWithKeyInfo) => {
try {
await updateProvider(provider.id, { enabled: !provider.enabled });
} catch (error) {
toast.error(`Failed to update provider: ${error}`);
}
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : providers.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Key className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No providers configured</h3>
<p className="text-muted-foreground text-center mb-4">
Add an AI provider to start using ClawX
</p>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isDefault={provider.id === defaultProviderId}
isEditing={editingProvider === provider.id}
onEdit={() => setEditingProvider(provider.id)}
onCancelEdit={() => setEditingProvider(null)}
onDelete={() => handleDeleteProvider(provider.id)}
onSetDefault={() => handleSetDefault(provider.id)}
onToggleEnabled={() => handleToggleEnabled(provider)}
onUpdateKey={async (key) => {
await setApiKey(provider.id, key);
setEditingProvider(null);
}}
onValidateKey={(key) => validateApiKey(provider.id, key)}
/>
))}
</div>
)}
{/* Add Provider Dialog */}
{showAddDialog && (
<AddProviderDialog
onClose={() => setShowAddDialog(false)}
onAdd={handleAddProvider}
/>
)}
</div>
);
}
interface ProviderCardProps {
provider: ProviderWithKeyInfo;
isDefault: boolean;
isEditing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onDelete: () => void;
onSetDefault: () => void;
onToggleEnabled: () => void;
onUpdateKey: (key: string) => Promise<void>;
onValidateKey: (key: string) => Promise<{ valid: boolean; error?: string }>;
}
/**
* Shorten a masked key to a more readable format.
* e.g. "sk-or-v1-a20a****df67" -> "sk-...df67"
*/
function shortenKeyDisplay(masked: string | null): string {
if (!masked) return 'No key';
// Show first 4 chars + last 4 chars
if (masked.length > 12) {
const prefix = masked.substring(0, 4);
const suffix = masked.substring(masked.length - 4);
return `${prefix}...${suffix}`;
}
return masked;
}
function ProviderCard({
provider,
isDefault,
isEditing,
onEdit,
onCancelEdit,
onDelete,
onSetDefault,
onToggleEnabled,
onUpdateKey,
onValidateKey,
}: ProviderCardProps) {
const [newKey, setNewKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const typeInfo = providerTypes.find((t) => t.id === provider.type);
const handleSaveKey = async () => {
if (!newKey) return;
setValidating(true);
const result = await onValidateKey(newKey);
setValidating(false);
if (!result.valid) {
toast.error(result.error || 'Invalid API key');
return;
}
setSaving(true);
try {
await onUpdateKey(newKey);
setNewKey('');
toast.success('API key updated');
} catch (error) {
toast.error(`Failed to save key: ${error}`);
} finally {
setSaving(false);
}
};
return (
<Card className={cn(isDefault && 'ring-2 ring-primary')}>
<CardContent className="p-4">
{/* Top row: icon + name + toggle */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-xl">{typeInfo?.icon || '⚙️'}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold">{provider.name}</span>
{isDefault && (
<Badge variant="default" className="text-xs">Default</Badge>
)}
</div>
<span className="text-xs text-muted-foreground capitalize">{provider.type}</span>
</div>
</div>
<Switch
checked={provider.enabled}
onCheckedChange={onToggleEnabled}
/>
</div>
{/* Key row */}
{isEditing ? (
<div className="space-y-2">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.placeholder}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 h-9 text-sm"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveKey}
disabled={!newKey || validating || saving}
>
{validating || saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelEdit}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey ? shortenKeyDisplay(provider.keyMasked) : 'No API key set'}
</span>
{provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">Configured</Badge>
)}
</div>
<div className="flex gap-0.5 shrink-0 ml-2">
{!isDefault && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onSetDefault} title="Set as default">
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit} title="Edit API key">
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDelete} title="Delete provider">
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface AddProviderDialogProps {
onClose: () => void;
onAdd: (type: string, name: string, apiKey: string) => Promise<void>;
}
function AddProviderDialog({ onClose, onAdd }: AddProviderDialogProps) {
const [selectedType, setSelectedType] = useState<string | null>(null);
const [name, setName] = useState('');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const typeInfo = providerTypes.find((t) => t.id === selectedType);
const handleAdd = async () => {
if (!selectedType) return;
setSaving(true);
try {
await onAdd(selectedType, name || typeInfo?.name || selectedType, apiKey);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Add AI Provider</CardTitle>
<CardDescription>
Configure a new AI model provider
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!selectedType ? (
<div className="grid grid-cols-2 gap-3">
{providerTypes.map((type) => (
<button
key={type.id}
onClick={() => {
setSelectedType(type.id);
setName(type.name);
}}
className="p-4 rounded-lg border hover:bg-accent transition-colors text-center"
>
<span className="text-2xl">{type.icon}</span>
<p className="font-medium mt-2">{type.name}</p>
</button>
))}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<span className="text-2xl">{typeInfo?.icon}</span>
<div>
<p className="font-medium">{typeInfo?.name}</p>
<button
onClick={() => setSelectedType(null)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Change provider
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
placeholder={typeInfo?.name}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<div className="relative">
<Input
id="apiKey"
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.placeholder}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground">
Your API key will be securely encrypted and stored locally.
</p>
</div>
</div>
)}
<Separator />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={!selectedType || saving}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Add Provider
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,207 @@
/**
* Update Settings Component
* Displays update status and allows manual update checking/installation
*/
import { useEffect, useCallback } from 'react';
import { Download, RefreshCw, CheckCircle2, AlertCircle, Loader2, Rocket } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useUpdateStore } from '@/stores/update';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function UpdateSettings() {
const {
status,
currentVersion,
updateInfo,
progress,
error,
isInitialized,
init,
checkForUpdates,
downloadUpdate,
installUpdate,
clearError,
} = useUpdateStore();
// Initialize on mount
useEffect(() => {
init();
}, [init]);
const handleCheckForUpdates = useCallback(async () => {
clearError();
await checkForUpdates();
}, [checkForUpdates, clearError]);
const renderStatusIcon = () => {
switch (status) {
case 'checking':
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
case 'downloading':
return <Download className="h-5 w-5 text-blue-500 animate-pulse" />;
case 'available':
return <Download className="h-5 w-5 text-green-500" />;
case 'downloaded':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'not-available':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
default:
return <RefreshCw className="h-5 w-5 text-muted-foreground" />;
}
};
const renderStatusText = () => {
switch (status) {
case 'checking':
return 'Checking for updates...';
case 'downloading':
return 'Downloading update...';
case 'available':
return `Update available: v${updateInfo?.version}`;
case 'downloaded':
return `Ready to install: v${updateInfo?.version}`;
case 'error':
return error || 'Update check failed';
case 'not-available':
return 'You have the latest version';
default:
return 'Check for updates to get the latest features';
}
};
const renderAction = () => {
switch (status) {
case 'checking':
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</Button>
);
case 'downloading':
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
</Button>
);
case 'available':
return (
<Button onClick={downloadUpdate} size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
</Button>
);
case 'downloaded':
return (
<Button onClick={installUpdate} size="sm" variant="default">
<Rocket className="h-4 w-4 mr-2" />
Install & Restart
</Button>
);
case 'error':
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
);
default:
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Check for Updates
</Button>
);
}
};
if (!isInitialized) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
);
}
return (
<div className="space-y-4">
{/* Current Version */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Current Version</p>
<p className="text-2xl font-bold">v{currentVersion}</p>
</div>
{renderStatusIcon()}
</div>
{/* Status */}
<div className="flex items-center justify-between py-3 border-t border-b">
<p className="text-sm text-muted-foreground">{renderStatusText()}</p>
{renderAction()}
</div>
{/* Download Progress */}
{status === 'downloading' && progress && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>
{formatBytes(progress.transferred)} / {formatBytes(progress.total)}
</span>
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
</div>
<Progress value={progress.percent} className="h-2" />
<p className="text-xs text-muted-foreground text-center">
{Math.round(progress.percent)}% complete
</p>
</div>
)}
{/* Update Info */}
{updateInfo && (status === 'available' || status === 'downloaded') && (
<div className="rounded-lg bg-muted p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="font-medium">Version {updateInfo.version}</p>
{updateInfo.releaseDate && (
<p className="text-sm text-muted-foreground">
{new Date(updateInfo.releaseDate).toLocaleDateString()}
</p>
)}
</div>
{updateInfo.releaseNotes && (
<div className="text-sm text-muted-foreground prose prose-sm max-w-none">
<p className="font-medium text-foreground mb-1">What's New:</p>
<p className="whitespace-pre-wrap">{updateInfo.releaseNotes}</p>
</div>
)}
</div>
)}
{/* Error Details */}
{status === 'error' && error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/10 p-4 text-red-600 dark:text-red-400 text-sm">
<p className="font-medium mb-1">Error Details:</p>
<p>{error}</p>
</div>
)}
{/* Help Text */}
<p className="text-xs text-muted-foreground">
Updates are downloaded in the background and installed when you restart the app.
</p>
</div>
);
}
export default UpdateSettings;

View File

@@ -0,0 +1,44 @@
/* eslint-disable react-refresh/only-export-components */
/**
* 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 };

View File

@@ -0,0 +1,60 @@
/* eslint-disable react-refresh/only-export-components */
/**
* 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 };

View 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 };

View File

@@ -0,0 +1,27 @@
/**
* Input Component
* Based on shadcn/ui input
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
export type InputProps = 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 };

View 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 };

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View 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 };

View 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 };

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background 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 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

72
src/lib/utils.ts Normal file
View 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
View 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>,
);

View File

@@ -0,0 +1,569 @@
/**
* Channels Page
* Manage messaging channel connections
*/
import { useState, useEffect } from 'react';
import {
Plus,
Radio,
RefreshCw,
Settings,
Trash2,
Power,
PowerOff,
QrCode,
Loader2,
X,
ExternalLink,
Copy,
Check,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway';
import { StatusBadge, type Status } from '@/components/common/StatusBadge';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType, type Channel } from '@/types/channel';
import { toast } from 'sonner';
// Channel type info with connection instructions
const channelInfo: Record<ChannelType, {
description: string;
connectionType: 'qr' | 'token' | 'oauth';
instructions: string[];
tokenLabel?: string;
docsUrl?: string;
}> = {
whatsapp: {
description: 'Connect WhatsApp by scanning a QR code',
connectionType: 'qr',
instructions: [
'Open WhatsApp on your phone',
'Go to Settings > Linked Devices',
'Tap "Link a Device"',
'Scan the QR code below',
],
docsUrl: 'https://faq.whatsapp.com/1317564962315842',
},
telegram: {
description: 'Connect Telegram using a bot token',
connectionType: 'token',
instructions: [
'Open Telegram and search for @BotFather',
'Send /newbot and follow the instructions',
'Copy the bot token provided',
'Paste it below',
],
tokenLabel: 'Bot Token',
docsUrl: 'https://core.telegram.org/bots#how-do-i-create-a-bot',
},
discord: {
description: 'Connect Discord using a bot token',
connectionType: 'token',
instructions: [
'Go to Discord Developer Portal',
'Create a new Application',
'Go to Bot section and create a bot',
'Copy the bot token',
],
tokenLabel: 'Bot Token',
docsUrl: 'https://discord.com/developers/applications',
},
slack: {
description: 'Connect Slack via OAuth',
connectionType: 'token',
instructions: [
'Go to Slack API apps page',
'Create a new app',
'Configure OAuth scopes',
'Install to workspace and copy the token',
],
tokenLabel: 'Bot Token (xoxb-...)',
docsUrl: 'https://api.slack.com/apps',
},
wechat: {
description: 'Connect WeChat by scanning a QR code',
connectionType: 'qr',
instructions: [
'Open WeChat on your phone',
'Scan the QR code below',
'Confirm login on your phone',
],
},
};
export function Channels() {
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel, deleteChannel } = useChannelsStore();
const gatewayStatus = useGatewayStore((state) => state.status);
const [showAddDialog, setShowAddDialog] = useState(false);
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
const [connectingChannelId, setConnectingChannelId] = useState<string | null>(null);
// Fetch channels on mount
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
// Supported channel types for adding
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
// Connected/disconnected channel counts
const connectedCount = channels.filter((c) => c.status === 'connected').length;
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 onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Channel
</Button>
</div>
</div>
{/* Stats */}
<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">
<Radio className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length}</p>
<p className="text-sm text-muted-foreground">Total Channels</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">
<Power className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold">{connectedCount}</p>
<p className="text-sm text-muted-foreground">Connected</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-slate-100 p-3 dark:bg-slate-800">
<PowerOff className="h-6 w-6 text-slate-600" />
</div>
<div>
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
<p className="text-sm text-muted-foreground">Disconnected</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Gateway Warning */}
{gatewayStatus.state !== 'running' && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Channels cannot connect without an active Gateway.
</span>
</CardContent>
</Card>
)}
{/* 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 onClick={() => setShowAddDialog(true)}>
<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) => (
<ChannelCard
key={channel.id}
channel={channel}
onConnect={() => {
setConnectingChannelId(channel.id);
connectChannel(channel.id);
}}
onDisconnect={() => disconnectChannel(channel.id)}
onDelete={() => {
if (confirm('Are you sure you want to delete this channel?')) {
deleteChannel(channel.id);
}
}}
isConnecting={connectingChannelId === channel.id}
/>
))}
</div>
)}
{/* Add Channel Section */}
<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"
onClick={() => {
setSelectedChannelType(type);
setShowAddDialog(true);
}}
>
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
<span>{CHANNEL_NAMES[type]}</span>
</Button>
))}
</div>
</CardContent>
</Card>
{/* Add Channel Dialog */}
{showAddDialog && (
<AddChannelDialog
selectedType={selectedChannelType}
onSelectType={setSelectedChannelType}
onClose={() => {
setShowAddDialog(false);
setSelectedChannelType(null);
}}
supportedTypes={supportedTypes}
/>
)}
</div>
);
}
// ==================== Channel Card Component ====================
interface ChannelCardProps {
channel: Channel;
onConnect: () => void;
onDisconnect: () => void;
onDelete: () => void;
isConnecting: boolean;
}
function ChannelCard({ channel, onConnect, onDisconnect, onDelete, isConnecting }: ChannelCardProps) {
return (
<Card>
<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 as 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={onDisconnect}
>
<PowerOff className="h-4 w-4 mr-2" />
Disconnect
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={onConnect}
disabled={channel.status === 'connecting' || isConnecting}
>
{channel.status === 'connecting' || isConnecting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Connecting...
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
)}
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
}
// ==================== Add Channel Dialog ====================
interface AddChannelDialogProps {
selectedType: ChannelType | null;
onSelectType: (type: ChannelType | null) => void;
onClose: () => void;
supportedTypes: ChannelType[];
}
function AddChannelDialog({ selectedType, onSelectType, onClose, supportedTypes }: AddChannelDialogProps) {
const { addChannel } = useChannelsStore();
const [channelName, setChannelName] = useState('');
const [token, setToken] = useState('');
const [connecting, setConnecting] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const info = selectedType ? channelInfo[selectedType] : null;
const handleConnect = async () => {
if (!selectedType) return;
setConnecting(true);
try {
// For QR-based channels, we'd request a QR code from the gateway
if (info?.connectionType === 'qr') {
// Simulate QR code generation
await new Promise((resolve) => setTimeout(resolve, 1500));
setQrCode('placeholder-qr');
} else {
// For token-based, add the channel with the token
await addChannel({
type: selectedType,
name: channelName || CHANNEL_NAMES[selectedType],
token: token || undefined,
});
toast.success(`${CHANNEL_NAMES[selectedType]} channel added`);
onClose();
}
} catch (error) {
toast.error(`Failed to add channel: ${error}`);
} finally {
setConnecting(false);
}
};
const copyToken = () => {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>
{selectedType ? `Connect ${CHANNEL_NAMES[selectedType]}` : 'Add Channel'}
</CardTitle>
<CardDescription>
{info?.description || 'Select a messaging channel to connect'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!selectedType ? (
// Channel type selection
<div className="grid grid-cols-2 gap-4">
{supportedTypes.map((type) => (
<button
key={type}
onClick={() => onSelectType(type)}
className="p-4 rounded-lg border hover:bg-accent transition-colors text-center"
>
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
<p className="font-medium mt-2">{CHANNEL_NAMES[type]}</p>
<p className="text-xs text-muted-foreground mt-1">
{channelInfo[type].connectionType === 'qr' ? 'QR Code' : 'Token'}
</p>
</button>
))}
</div>
) : qrCode ? (
// QR Code display
<div className="text-center space-y-4">
<div className="bg-white p-4 rounded-lg inline-block">
<div className="w-48 h-48 bg-gray-100 flex items-center justify-center">
<QrCode className="h-32 w-32 text-gray-400" />
</div>
</div>
<p className="text-sm text-muted-foreground">
Scan this QR code with {CHANNEL_NAMES[selectedType]} to connect
</p>
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={() => setQrCode(null)}>
Generate New Code
</Button>
<Button onClick={() => {
toast.success('Channel connected successfully');
onClose();
}}>
I've Scanned It
</Button>
</div>
</div>
) : (
// Connection form
<div className="space-y-4">
{/* Instructions */}
<div className="bg-muted p-4 rounded-lg space-y-2">
<p className="font-medium text-sm">How to connect:</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground space-y-1">
{info?.instructions.map((instruction, i) => (
<li key={i}>{instruction}</li>
))}
</ol>
{info?.docsUrl && (
<Button
variant="link"
className="p-0 h-auto text-sm"
onClick={() => window.electron.openExternal(info.docsUrl!)}
>
View documentation
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
)}
</div>
{/* Channel name */}
<div className="space-y-2">
<Label htmlFor="name">Channel Name (optional)</Label>
<Input
id="name"
placeholder={`My ${CHANNEL_NAMES[selectedType]}`}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
/>
</div>
{/* Token input for token-based channels */}
{info?.connectionType === 'token' && (
<div className="space-y-2">
<Label htmlFor="token">{info.tokenLabel || 'Token'}</Label>
<div className="flex gap-2">
<Input
id="token"
type="password"
placeholder="Paste your token here"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
{token && (
<Button variant="outline" size="icon" onClick={copyToken}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
)}
</div>
</div>
)}
<Separator />
<div className="flex justify-between">
<Button variant="outline" onClick={() => onSelectType(null)}>
Back
</Button>
<Button
onClick={handleConnect}
disabled={connecting || (info?.connectionType === 'token' && !token)}
>
{connecting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{info?.connectionType === 'qr' ? 'Generating QR...' : 'Connecting...'}
</>
) : info?.connectionType === 'qr' ? (
'Generate QR Code'
) : (
'Connect'
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default Channels;

View File

@@ -0,0 +1,82 @@
/**
* Chat Input Component
* Textarea with send button. Enter to send, Shift+Enter for new line.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
sending?: boolean;
}
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
}, [input]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || disabled || sending) return;
onSend(trimmed);
setInput('');
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, disabled, sending, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="border-t bg-background p-4">
<div className="flex items-end gap-2 max-w-4xl mx-auto">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
disabled={disabled}
className="min-h-[44px] max-h-[200px] resize-none pr-4"
rows={1}
/>
</div>
<Button
onClick={handleSend}
disabled={!input.trim() || disabled}
size="icon"
className="shrink-0 h-10 w-10"
variant={sending ? 'destructive' : 'default'}
>
{sending ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
/**
* Chat Message Component
* Renders user / assistant / system / toolresult messages
* with markdown, thinking sections, images, and tool cards.
*/
import { useState, useCallback, memo } from 'react';
import { User, Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { RawMessage } from '@/stores/chat';
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
interface ChatMessageProps {
message: RawMessage;
showThinking: boolean;
isStreaming?: boolean;
}
export const ChatMessage = memo(function ChatMessage({
message,
showThinking,
isStreaming = false,
}: ChatMessageProps) {
const isUser = message.role === 'user';
const isToolResult = message.role === 'toolresult';
const text = extractText(message);
const thinking = extractThinking(message);
const images = extractImages(message);
const tools = extractToolUse(message);
// Don't render empty tool results when thinking is hidden
if (isToolResult && !showThinking) return null;
// Don't render empty messages
if (!text && !thinking && images.length === 0 && tools.length === 0) return null;
return (
<div
className={cn(
'flex gap-3 group',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{/* Avatar */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white',
)}
>
{isUser ? <User className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
</div>
{/* Content */}
<div className={cn('max-w-[80%] space-y-2', isUser && 'items-end')}>
{/* Thinking section */}
{showThinking && thinking && (
<ThinkingBlock content={thinking} />
)}
{/* Tool use cards */}
{showThinking && tools.length > 0 && (
<div className="space-y-1">
{tools.map((tool, i) => (
<ToolCard key={tool.id || i} name={tool.name} input={tool.input} />
))}
</div>
)}
{/* Main text bubble */}
{text && (
<MessageBubble
text={text}
isUser={isUser}
isStreaming={isStreaming}
timestamp={message.timestamp}
/>
)}
{/* Images */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<img
key={i}
src={`data:${img.mimeType};base64,${img.data}`}
alt="attachment"
className="max-w-xs rounded-lg border"
/>
))}
</div>
)}
</div>
</div>
);
});
// ── Message Bubble ──────────────────────────────────────────────
function MessageBubble({
text,
isUser,
isStreaming,
timestamp,
}: {
text: string;
isUser: boolean;
isStreaming: boolean;
timestamp?: number;
}) {
const [copied, setCopied] = useState(false);
const copyContent = useCallback(() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<div
className={cn(
'relative rounded-2xl px-4 py-3',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)}
>
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{text}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className;
if (isInline) {
return (
<code className="bg-background/50 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
);
}
return (
<pre className="bg-background/50 rounded-lg p-4 overflow-x-auto">
<code className={cn('text-sm font-mono', className)} {...props}>
{children}
</code>
</pre>
);
},
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{children}
</a>
);
},
}}
>
{text}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
)}
</div>
)}
{/* Footer: timestamp + copy */}
<div className={cn(
'flex items-center gap-2 mt-2',
isUser ? 'justify-end' : 'justify-between',
)}>
{timestamp && (
<span className={cn(
'text-xs',
isUser ? 'text-primary-foreground/60' : 'text-muted-foreground',
)}>
{formatTimestamp(timestamp)}
</span>
)}
{!isUser && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={copyContent}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
</div>
);
}
// ── Thinking Block ──────────────────────────────────────────────
function ThinkingBlock({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-border/50 bg-muted/30 text-sm">
<button
className="flex items-center gap-2 w-full px-3 py-2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span className="font-medium">Thinking</span>
</button>
{expanded && (
<div className="px-3 pb-3 text-muted-foreground">
<div className="prose prose-sm dark:prose-invert max-w-none opacity-75">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
)}
</div>
);
}
// ── Tool Card ───────────────────────────────────────────────────
function ToolCard({ name, input }: { name: string; input: unknown }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-border/50 bg-muted/20 text-sm">
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded(!expanded)}
>
<Wrench className="h-3.5 w-3.5" />
<span className="font-mono text-xs">{name}</span>
{expanded ? <ChevronDown className="h-3 w-3 ml-auto" /> : <ChevronRight className="h-3 w-3 ml-auto" />}
</button>
{expanded && input != null && (
<pre className="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto">
{typeof input === 'string' ? input : JSON.stringify(input, null, 2) as string}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Chat Toolbar
* Session selector, new session, refresh, and thinking toggle.
* Rendered in the Header when on the Chat page.
*/
import { RefreshCw, Brain, ChevronDown, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useChatStore } from '@/stores/chat';
import { cn } from '@/lib/utils';
export function ChatToolbar() {
const sessions = useChatStore((s) => s.sessions);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const switchSession = useChatStore((s) => s.switchSession);
const newSession = useChatStore((s) => s.newSession);
const refresh = useChatStore((s) => s.refresh);
const loading = useChatStore((s) => s.loading);
const showThinking = useChatStore((s) => s.showThinking);
const toggleThinking = useChatStore((s) => s.toggleThinking);
const handleSessionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
switchSession(e.target.value);
};
return (
<div className="flex items-center gap-2">
{/* Session Selector */}
<div className="relative">
<select
value={currentSessionKey}
onChange={handleSessionChange}
className={cn(
'appearance-none rounded-md border border-border bg-background px-3 py-1.5 pr-8',
'text-sm text-foreground cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-ring',
)}
>
{/* Always show current session */}
<option value={currentSessionKey}>
{sessions.find((s) => s.key === currentSessionKey)?.displayName
|| sessions.find((s) => s.key === currentSessionKey)?.label
|| currentSessionKey}
</option>
{/* Other sessions */}
{sessions
.filter((s) => s.key !== currentSessionKey)
.map((s) => (
<option key={s.key} value={s.key}>
{s.displayName || s.label || s.key}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
{/* New Session */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={newSession}
title="New session"
>
<Plus className="h-4 w-4" />
</Button>
{/* Refresh */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => refresh()}
disabled={loading}
title="Refresh chat"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
{/* Thinking Toggle */}
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8',
showThinking && 'bg-primary/10 text-primary',
)}
onClick={toggleThinking}
title={showThinking ? 'Hide thinking' : 'Show thinking'}
>
<Brain className="h-4 w-4" />
</Button>
</div>
);
}

188
src/pages/Chat/index.tsx Normal file
View File

@@ -0,0 +1,188 @@
/**
* Chat Page
* Native React implementation communicating with OpenClaw Gateway
* via gateway:rpc IPC. Session selector, thinking toggle, and refresh
* are in the toolbar; messages render with markdown + streaming.
*/
import { useEffect, useRef } from 'react';
import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { extractText } from './message-utils';
export function Chat() {
const gatewayStatus = useGatewayStore((s) => s.status);
const isGatewayRunning = gatewayStatus.state === 'running';
const messages = useChatStore((s) => s.messages);
const loading = useChatStore((s) => s.loading);
const sending = useChatStore((s) => s.sending);
const error = useChatStore((s) => s.error);
const showThinking = useChatStore((s) => s.showThinking);
const streamingMessage = useChatStore((s) => s.streamingMessage);
const loadHistory = useChatStore((s) => s.loadHistory);
const loadSessions = useChatStore((s) => s.loadSessions);
const sendMessage = useChatStore((s) => s.sendMessage);
const clearError = useChatStore((s) => s.clearError);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Load data when gateway is running
useEffect(() => {
if (isGatewayRunning) {
loadHistory();
loadSessions();
}
}, [isGatewayRunning, loadHistory, loadSessions]);
// Auto-scroll on new messages or streaming
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingMessage, sending]);
// Gateway not running
if (!isGatewayRunning) {
return (
<div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center text-center p-8">
<AlertCircle className="h-12 w-12 text-yellow-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">Gateway Not Running</h2>
<p className="text-muted-foreground max-w-md">
The OpenClaw Gateway needs to be running to use chat.
It will start automatically, or you can start it from Settings.
</p>
</div>
);
}
// Extract streaming text for display
const streamText = streamingMessage ? extractText(streamingMessage) : '';
return (
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 3.5rem)' }}>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="max-w-4xl mx-auto space-y-4">
{loading ? (
<div className="flex h-full items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
) : messages.length === 0 && !sending ? (
<WelcomeScreen />
) : (
<>
{messages.map((msg, idx) => (
<ChatMessage
key={msg.id || `msg-${idx}`}
message={msg}
showThinking={showThinking}
/>
))}
{/* Streaming message */}
{sending && streamText && (
<ChatMessage
message={{
role: 'assistant',
content: streamingMessage as unknown as string,
timestamp: Date.now() / 1000,
}}
showThinking={showThinking}
isStreaming
/>
)}
{/* Typing indicator when sending but no stream yet */}
{sending && !streamText && (
<TypingIndicator />
)}
</>
)}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
</div>
{/* Error bar */}
{error && (
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<p className="text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{error}
</p>
<button
onClick={clearError}
className="text-xs text-destructive/60 hover:text-destructive underline"
>
Dismiss
</button>
</div>
</div>
)}
{/* Input Area */}
<ChatInput
onSend={sendMessage}
disabled={!isGatewayRunning}
sending={sending}
/>
</div>
);
}
// ── Welcome Screen ──────────────────────────────────────────────
function WelcomeScreen() {
return (
<div className="flex flex-col items-center justify-center text-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-6">
<Bot className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-bold mb-2">ClawX Chat</h2>
<p className="text-muted-foreground mb-8 max-w-md">
Your AI assistant is ready. Start a conversation below.
</p>
<div className="grid grid-cols-2 gap-4 max-w-lg w-full">
{[
{ icon: MessageSquare, title: 'Ask Questions', desc: 'Get answers on any topic' },
{ icon: Sparkles, title: 'Creative Tasks', desc: 'Writing, brainstorming, ideas' },
].map((item, i) => (
<Card key={i} className="text-left">
<CardContent className="p-4">
<item.icon className="h-6 w-6 text-primary mb-2" />
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// ── Typing Indicator ────────────────────────────────────────────
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
<Sparkles className="h-4 w-4" />
</div>
<div className="bg-muted rounded-2xl px-4 py-3">
<div className="flex gap-1">
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
</div>
);
}
export default Chat;

View File

@@ -0,0 +1,131 @@
/**
* Message content extraction helpers
* Ported from OpenClaw's message-extract.ts to handle the various
* message content formats returned by the Gateway.
*/
import type { RawMessage, ContentBlock } from '@/stores/chat';
/**
* Extract displayable text from a message's content field.
* Handles both string content and array-of-blocks content.
*/
export function extractText(message: RawMessage | unknown): string {
if (!message || typeof message !== 'object') return '';
const msg = message as Record<string, unknown>;
const content = msg.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text) {
parts.push(block.text);
}
// tool_result blocks may have nested text
if (block.type === 'tool_result' && typeof block.content === 'string') {
parts.push(block.content);
}
}
return parts.join('\n\n');
}
// Fallback: try .text field
if (typeof msg.text === 'string') {
return msg.text;
}
return '';
}
/**
* Extract thinking/reasoning content from a message.
* Returns null if no thinking content found.
*/
export function extractThinking(message: RawMessage | unknown): string | null {
if (!message || typeof message !== 'object') return null;
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'thinking' && block.thinking) {
parts.push(block.thinking);
}
}
return parts.length > 0 ? parts.join('\n\n') : null;
}
/**
* Extract image attachments from a message.
* Returns array of { mimeType, data } for base64 images.
*/
export function extractImages(message: RawMessage | unknown): Array<{ mimeType: string; data: string }> {
if (!message || typeof message !== 'object') return [];
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return [];
const images: Array<{ mimeType: string; data: string }> = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'image' && block.source) {
const src = block.source;
if (src.type === 'base64' && src.media_type && src.data) {
images.push({ mimeType: src.media_type, data: src.data });
}
}
}
return images;
}
/**
* Extract tool use blocks from a message.
*/
export function extractToolUse(message: RawMessage | unknown): Array<{ id: string; name: string; input: unknown }> {
if (!message || typeof message !== 'object') return [];
const msg = message as Record<string, unknown>;
const content = msg.content;
if (!Array.isArray(content)) return [];
const tools: Array<{ id: string; name: string; input: unknown }> = [];
for (const block of content as ContentBlock[]) {
if (block.type === 'tool_use' && block.name) {
tools.push({
id: block.id || '',
name: block.name,
input: block.input,
});
}
}
return tools;
}
/**
* Format a Unix timestamp (seconds) to relative time string.
*/
export function formatTimestamp(timestamp: unknown): string {
if (!timestamp) return '';
const ts = typeof timestamp === 'number' ? timestamp : Number(timestamp);
if (!ts || isNaN(ts)) return '';
// OpenClaw timestamps can be in seconds or milliseconds
const ms = ts > 1e12 ? ts : ts * 1000;
const date = new Date(ms);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
if (diffMs < 60000) return 'just now';
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

633
src/pages/Cron/index.tsx Normal file
View File

@@ -0,0 +1,633 @@
/**
* Cron Page
* Manage scheduled tasks
*/
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Clock,
Play,
Pause,
Trash2,
Edit,
RefreshCw,
X,
Calendar,
AlertCircle,
CheckCircle2,
XCircle,
MessageSquare,
Loader2,
Timer,
History,
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useCronStore } from '@/stores/cron';
import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { formatRelativeTime, cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
import { CHANNEL_ICONS } from '@/types/channel';
// Common cron schedule presets
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
{ label: 'Every minute', value: '* * * * *', type: 'interval' },
{ label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' },
{ label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' },
{ label: 'Every hour', value: '0 * * * *', type: 'interval' },
{ label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' },
{ label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' },
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' },
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
];
// Parse cron expression to human-readable format
function parseCronSchedule(cron: string): string {
const preset = schedulePresets.find((p) => p.value === cron);
if (preset) return preset.label;
const parts = cron.split(' ');
if (parts.length !== 5) return cron;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return 'Every minute';
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
if (hour === '*' && minute === '0') return 'Every hour';
if (dayOfWeek !== '*' && dayOfMonth === '*') {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`;
}
if (dayOfMonth !== '*') {
return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`;
}
if (hour !== '*') {
return `Daily at ${hour}:${minute.padStart(2, '0')}`;
}
return cron;
}
// Create/Edit Task Dialog
interface TaskDialogProps {
job?: CronJob;
onClose: () => void;
onSave: (input: CronJobCreateInput) => Promise<void>;
}
function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const { channels } = useChannelsStore();
const [saving, setSaving] = useState(false);
const [name, setName] = useState(job?.name || '');
const [message, setMessage] = useState(job?.message || '');
const [schedule, setSchedule] = useState(job?.schedule || '0 9 * * *');
const [customSchedule, setCustomSchedule] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [channelId, setChannelId] = useState(job?.target.channelId || '');
const [enabled, setEnabled] = useState(job?.enabled ?? true);
const selectedChannel = channels.find((c) => c.id === channelId);
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('Please enter a task name');
return;
}
if (!message.trim()) {
toast.error('Please enter a message');
return;
}
if (!channelId) {
toast.error('Please select a channel');
return;
}
const finalSchedule = useCustom ? customSchedule : schedule;
if (!finalSchedule.trim()) {
toast.error('Please select or enter a schedule');
return;
}
setSaving(true);
try {
await onSave({
name: name.trim(),
message: message.trim(),
schedule: finalSchedule,
target: {
channelType: selectedChannel!.type,
channelId: selectedChannel!.id,
channelName: selectedChannel!.name,
},
enabled,
});
onClose();
toast.success(job ? 'Task updated' : 'Task created');
} catch (err) {
toast.error(String(err));
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={onClose}>
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>{job ? 'Edit Task' : 'Create Task'}</CardTitle>
<CardDescription>Schedule an automated AI task</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Task Name</Label>
<Input
id="name"
placeholder="e.g., Morning briefing"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{/* Message */}
<div className="space-y-2">
<Label htmlFor="message">Message / Prompt</Label>
<Textarea
id="message"
placeholder="What should the AI do? e.g., Give me a summary of today's news and weather"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
/>
</div>
{/* Schedule */}
<div className="space-y-2">
<Label>Schedule</Label>
{!useCustom ? (
<div className="grid grid-cols-2 gap-2">
{schedulePresets.map((preset) => (
<Button
key={preset.value}
type="button"
variant={schedule === preset.value ? 'default' : 'outline'}
size="sm"
onClick={() => setSchedule(preset.value)}
className="justify-start"
>
<Timer className="h-4 w-4 mr-2" />
{preset.label}
</Button>
))}
</div>
) : (
<Input
placeholder="Cron expression (e.g., 0 9 * * *)"
value={customSchedule}
onChange={(e) => setCustomSchedule(e.target.value)}
/>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setUseCustom(!useCustom)}
className="text-xs"
>
{useCustom ? 'Use presets' : 'Use custom cron'}
</Button>
</div>
{/* Target Channel */}
<div className="space-y-2">
<Label>Target Channel</Label>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channels available. Add a channel first.
</p>
) : (
<div className="grid grid-cols-2 gap-2">
{channels.map((channel) => (
<Button
key={channel.id}
type="button"
variant={channelId === channel.id ? 'default' : 'outline'}
size="sm"
onClick={() => setChannelId(channel.id)}
className="justify-start"
>
<span className="mr-2">{CHANNEL_ICONS[channel.type]}</span>
{channel.name}
</Button>
))}
</div>
)}
</div>
{/* Enabled */}
<div className="flex items-center justify-between">
<div>
<Label>Enable immediately</Label>
<p className="text-sm text-muted-foreground">
Start running this task after creation
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{job ? 'Save Changes' : 'Create Task'}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Job Card Component
interface CronJobCardProps {
job: CronJob;
onToggle: (enabled: boolean) => void;
onEdit: () => void;
onDelete: () => void;
onTrigger: () => void;
}
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
const [triggering, setTriggering] = useState(false);
const handleTrigger = async () => {
setTriggering(true);
try {
await onTrigger();
toast.success('Task triggered');
} finally {
setTriggering(false);
}
};
const handleDelete = () => {
if (confirm('Are you sure you want to delete this task?')) {
onDelete();
}
};
return (
<Card className={cn(
'transition-colors',
job.enabled && 'border-primary/30'
)}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={cn(
'rounded-full p-2',
job.enabled
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-muted'
)}>
<Clock className={cn(
'h-5 w-5',
job.enabled ? 'text-green-600' : 'text-muted-foreground'
)} />
</div>
<div>
<CardTitle className="text-lg">{job.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Timer className="h-3 w-3" />
{parseCronSchedule(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 className="space-y-4">
{/* Message Preview */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50">
<MessageSquare className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground line-clamp-2">
{job.message}
</p>
</div>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
{CHANNEL_ICONS[job.target.channelType]}
{job.target.channelName}
</span>
{job.lastRun && (
<span className="flex items-center gap-1">
<History className="h-4 w-4" />
Last: {formatRelativeTime(job.lastRun.time)}
{job.lastRun.success ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
</span>
)}
{job.nextRun && job.enabled && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Next: {new Date(job.nextRun).toLocaleString()}
</span>
)}
</div>
{/* Last Run Error */}
{job.lastRun && !job.lastRun.success && job.lastRun.error && (
<div className="flex items-start gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{job.lastRun.error}</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-1 pt-2 border-t">
<Button
variant="ghost"
size="sm"
onClick={handleTrigger}
disabled={triggering}
>
{triggering ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="ml-1">Run Now</span>
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4" />
<span className="ml-1">Edit</span>
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
<span className="ml-1 text-destructive">Delete</span>
</Button>
</div>
</CardContent>
</Card>
);
}
export function Cron() {
const { jobs, loading, error, fetchJobs, createJob, updateJob, toggleJob, deleteJob, triggerJob } = useCronStore();
const { fetchChannels } = useChannelsStore();
const gatewayStatus = useGatewayStore((state) => state.status);
const [showDialog, setShowDialog] = useState(false);
const [editingJob, setEditingJob] = useState<CronJob | undefined>();
const isGatewayRunning = gatewayStatus.state === 'running';
// Fetch jobs and channels on mount
useEffect(() => {
if (isGatewayRunning) {
fetchJobs();
fetchChannels();
}
}, [fetchJobs, fetchChannels, isGatewayRunning]);
// Statistics
const activeJobs = jobs.filter((j) => j.enabled);
const pausedJobs = jobs.filter((j) => !j.enabled);
const failedJobs = jobs.filter((j) => j.lastRun && !j.lastRun.success);
const handleSave = useCallback(async (input: CronJobCreateInput) => {
if (editingJob) {
await updateJob(editingJob.id, input);
} else {
await createJob(input);
}
}, [editingJob, createJob, updateJob]);
const handleToggle = useCallback(async (id: string, enabled: boolean) => {
try {
await toggleJob(id, enabled);
toast.success(enabled ? 'Task enabled' : 'Task paused');
} catch {
toast.error('Failed to update task');
}
}, [toggleJob]);
const handleDelete = useCallback(async (id: string) => {
try {
await deleteJob(id);
toast.success('Task deleted');
} catch {
toast.error('Failed to delete task');
}
}, [deleteJob]);
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">Scheduled Tasks</h1>
<p className="text-muted-foreground">
Automate AI workflows with scheduled tasks
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchJobs} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button
onClick={() => {
setEditingJob(undefined);
setShowDialog(true);
}}
disabled={!isGatewayRunning}
>
<Plus className="h-4 w-4 mr-2" />
New Task
</Button>
</div>
</div>
{/* Gateway Warning */}
{!isGatewayRunning && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Scheduled tasks cannot be managed without an active Gateway.
</span>
</CardContent>
</Card>
)}
{/* Statistics */}
<div className="grid grid-cols-4 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/30">
<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">Active</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/30">
<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>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-red-100 p-3 dark:bg-red-900/30">
<XCircle className="h-6 w-6 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold">{failedJobs.length}</p>
<p className="text-sm text-muted-foreground">Failed</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{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 max-w-md">
Create scheduled tasks to automate AI workflows.
Tasks can send messages, run queries, or perform actions at specified times.
</p>
<Button
onClick={() => {
setEditingJob(undefined);
setShowDialog(true);
}}
disabled={!isGatewayRunning}
>
<Plus className="h-4 w-4 mr-2" />
Create Your First Task
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{jobs.map((job) => (
<CronJobCard
key={job.id}
job={job}
onToggle={(enabled) => handleToggle(job.id, enabled)}
onEdit={() => {
setEditingJob(job);
setShowDialog(true);
}}
onDelete={() => handleDelete(job.id)}
onTrigger={() => triggerJob(job.id)}
/>
))}
</div>
)}
{/* Create/Edit Dialog */}
{showDialog && (
<TaskDialog
job={editingJob}
onClose={() => {
setShowDialog(false);
setEditingJob(undefined);
}}
onSave={handleSave}
/>
)}
</div>
);
}
export default Cron;

View File

@@ -0,0 +1,252 @@
/**
* Dashboard Page
* Main overview page showing system status and quick actions
*/
import { useEffect } from 'react';
import {
Activity,
MessageSquare,
Radio,
Puzzle,
Clock,
Settings,
Plus,
} 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';
export function Dashboard() {
const gatewayStatus = useGatewayStore((state) => state.status);
const { channels, fetchChannels } = useChannelsStore();
const { skills, fetchSkills } = useSkillsStore();
const isGatewayRunning = gatewayStatus.state === 'running';
// Fetch data only when gateway is running
useEffect(() => {
if (isGatewayRunning) {
fetchChannels();
fetchSkills();
}
}, [fetchChannels, fetchSkills, isGatewayRunning]);
// Calculate statistics safely
const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0;
const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0;
// 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;

View File

@@ -0,0 +1,261 @@
/**
* Settings Page
* Application configuration
*/
import {
Sun,
Moon,
Monitor,
RefreshCw,
Terminal,
ExternalLink,
Key,
Download,
} 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';
import { useUpdateStore } from '@/stores/update';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
export function Settings() {
const {
theme,
setTheme,
gatewayAutoStart,
setGatewayAutoStart,
autoCheckUpdate,
setAutoCheckUpdate,
autoDownloadUpdate,
setAutoDownloadUpdate,
devModeUnlocked,
} = useSettingsStore();
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
const currentVersion = useUpdateStore((state) => state.currentVersion);
// Open developer console
const openDevConsole = () => {
window.electron.openExternal('http://localhost:18789');
};
return (
<div className="space-y-6 p-6">
<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>
{/* AI Providers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
AI Providers
</CardTitle>
<CardDescription>Configure your AI model providers and API keys</CardDescription>
</CardHeader>
<CardContent>
<ProvidersSettings />
</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 className="flex items-center gap-2">
<Download className="h-5 w-5" />
Updates
</CardTitle>
<CardDescription>Keep ClawX up to date</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UpdateSettings />
<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 {currentVersion}</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/ValueCell-ai/ClawX')}
>
GitHub
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export default Settings;

868
src/pages/Setup/index.tsx Normal file
View File

@@ -0,0 +1,868 @@
/**
* Setup Wizard Page
* First-time setup experience for new users
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
Eye,
EyeOff,
RefreshCw,
CheckCircle2,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway';
import { useSettingsStore } from '@/stores/settings';
import { toast } from 'sonner';
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',
},
// Skills selection removed - auto-install essential components
{
id: 'installing',
title: 'Setting Up',
description: 'Installing essential components',
},
{
id: 'complete',
title: 'All Set!',
description: 'ClawX is ready to use',
},
];
// Default skills to auto-install (no additional API keys required)
interface DefaultSkill {
id: string;
name: string;
description: string;
}
const defaultSkills: DefaultSkill[] = [
{ id: 'opencode', name: 'OpenCode', description: 'AI coding assistant backend' },
{ id: 'python-env', name: 'Python Environment', description: 'Python runtime for skills' },
{ id: 'code-assist', name: 'Code Assist', description: 'Code analysis and suggestions' },
{ id: 'file-tools', name: 'File Tools', description: 'File operations and management' },
{ id: 'terminal', name: 'Terminal', description: 'Shell command execution' },
];
// Provider types
interface Provider {
id: string;
name: string;
model: string;
icon: string;
placeholder: string;
}
const providers: Provider[] = [
{ id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖', placeholder: 'sk-ant-...' },
{ id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚', placeholder: 'sk-...' },
{ id: 'google', name: 'Google', model: 'Gemini', icon: '🔷', placeholder: 'AI...' },
{ id: 'openrouter', name: 'OpenRouter', model: 'Multi-Model', icon: '🌐', placeholder: 'sk-or-...' },
];
// NOTE: Channel types moved to Settings > Channels page
// NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup
export function Setup() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(0);
const [canProceed, setCanProceed] = useState(true);
// Setup state
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [apiKey, setApiKey] = useState('');
// Installation state for the Installing step
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
const step = steps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const markSetupComplete = useSettingsStore((state) => state.markSetupComplete);
const handleNext = async () => {
if (isLastStep) {
// Complete setup
markSetupComplete();
toast.success('Setup complete! Welcome to ClawX');
navigate('/');
} else {
setCurrentStep((i) => i + 1);
}
};
const handleBack = () => {
setCurrentStep((i) => Math.max(i - 1, 0));
};
const handleSkip = () => {
markSetupComplete();
navigate('/');
};
// Auto-proceed when installation is complete
const handleInstallationComplete = useCallback((skills: string[]) => {
setInstalledSkills(skills);
// Auto-proceed to next step after a short delay
setTimeout(() => {
setCurrentStep((i) => i + 1);
}, 1000);
}, []);
// Update canProceed based on current step
useEffect(() => {
switch (currentStep) {
case 0: // Welcome
setCanProceed(true);
break;
case 1: // Runtime
// Will be managed by RuntimeContent
break;
case 2: // Provider
setCanProceed(selectedProvider !== null && apiKey.length > 0);
break;
case 3: // Installing
setCanProceed(false); // Cannot manually proceed, auto-proceeds when done
break;
case 4: // Complete
setCanProceed(true);
break;
}
}, [currentStep, selectedProvider, apiKey]);
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 onStatusChange={setCanProceed} />}
{currentStep === 2 && (
<ProviderContent
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={setSelectedProvider}
apiKey={apiKey}
onApiKeyChange={setApiKey}
/>
)}
{currentStep === 3 && (
<InstallingContent
skills={defaultSkills}
onComplete={handleInstallationComplete}
/>
)}
{currentStep === 4 && (
<CompleteContent
selectedProvider={selectedProvider}
installedSkills={installedSkills}
/>
)}
</div>
{/* Navigation - hidden during installation step */}
{currentStep !== 3 && (
<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 && currentStep !== 1 && (
<Button variant="ghost" onClick={handleSkip}>
Skip Setup
</Button>
)}
<Button onClick={handleNext} disabled={!canProceed}>
{isLastStep ? (
'Get Started'
) : (
<>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
);
}
// ==================== Step Content Components ====================
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 className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Zero command-line required
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Modern, beautiful interface
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Pre-installed skill bundles
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Cross-platform support
</li>
</ul>
</div>
);
}
interface RuntimeContentProps {
onStatusChange: (canProceed: boolean) => void;
}
function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const gatewayStatus = useGatewayStore((state) => state.status);
const startGateway = useGatewayStore((state) => state.start);
const [checks, setChecks] = useState({
nodejs: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
openclaw: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
gateway: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
});
const runChecks = useCallback(async () => {
// Reset checks
setChecks({
nodejs: { status: 'checking', message: '' },
openclaw: { status: 'checking', message: '' },
gateway: { status: 'checking', message: '' },
});
// Check Node.js
try {
// In Electron, we can assume Node.js is available
setChecks((prev) => ({
...prev,
nodejs: { status: 'success', message: 'Node.js is available' },
}));
} catch {
setChecks((prev) => ({
...prev,
nodejs: { status: 'error', message: 'Node.js not found' },
}));
}
// Check OpenClaw submodule status
try {
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as {
submoduleExists: boolean;
isInstalled: boolean;
isBuilt: boolean;
dir: string;
};
if (!openclawStatus.submoduleExists) {
setChecks((prev) => ({
...prev,
openclaw: {
status: 'error',
message: 'OpenClaw submodule not found. Run: git submodule update --init'
},
}));
} else if (!openclawStatus.isInstalled) {
setChecks((prev) => ({
...prev,
openclaw: {
status: 'error',
message: 'Dependencies not installed. Run: cd openclaw && pnpm install'
},
}));
} else {
const modeLabel = openclawStatus.isBuilt ? 'production' : 'development';
setChecks((prev) => ({
...prev,
openclaw: {
status: 'success',
message: `OpenClaw package ready (${modeLabel} mode)`
},
}));
}
} catch (error) {
setChecks((prev) => ({
...prev,
openclaw: { status: 'error', message: `Failed to check: ${error}` },
}));
}
// Check Gateway
await new Promise((resolve) => setTimeout(resolve, 500));
if (gatewayStatus.state === 'running') {
setChecks((prev) => ({
...prev,
gateway: { status: 'success', message: `Running on port ${gatewayStatus.port}` },
}));
} else if (gatewayStatus.state === 'starting') {
setChecks((prev) => ({
...prev,
gateway: { status: 'checking', message: 'Starting...' },
}));
} else {
setChecks((prev) => ({
...prev,
gateway: { status: 'error', message: 'Not running' },
}));
}
}, [gatewayStatus]);
useEffect(() => {
runChecks();
}, [runChecks]);
// Update canProceed when gateway status changes
useEffect(() => {
const allPassed = checks.nodejs.status === 'success'
&& checks.openclaw.status === 'success'
&& (checks.gateway.status === 'success' || gatewayStatus.state === 'running');
onStatusChange(allPassed);
}, [checks, gatewayStatus, onStatusChange]);
// Update gateway check when gateway status changes
useEffect(() => {
if (gatewayStatus.state === 'running') {
setChecks((prev) => ({
...prev,
gateway: { status: 'success', message: `Running on port ${gatewayStatus.port}` },
}));
} else if (gatewayStatus.state === 'error') {
setChecks((prev) => ({
...prev,
gateway: { status: 'error', message: gatewayStatus.error || 'Failed to start' },
}));
}
}, [gatewayStatus]);
const handleStartGateway = async () => {
setChecks((prev) => ({
...prev,
gateway: { status: 'checking', message: 'Starting...' },
}));
await startGateway();
};
const renderStatus = (status: 'checking' | 'success' | 'error', message: string) => {
if (status === 'checking') {
return (
<span className="flex items-center gap-2 text-yellow-400">
<Loader2 className="h-4 w-4 animate-spin" />
{message || 'Checking...'}
</span>
);
}
if (status === 'success') {
return (
<span className="flex items-center gap-2 text-green-400">
<CheckCircle2 className="h-4 w-4" />
{message}
</span>
);
}
return (
<span className="flex items-center gap-2 text-red-400">
<XCircle className="h-4 w-4" />
{message}
</span>
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Checking Environment</h2>
<Button variant="ghost" size="sm" onClick={runChecks}>
<RefreshCw className="h-4 w-4 mr-2" />
Re-check
</Button>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Node.js Runtime</span>
{renderStatus(checks.nodejs.status, checks.nodejs.message)}
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>OpenClaw Package</span>
{renderStatus(checks.openclaw.status, checks.openclaw.message)}
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<div className="flex items-center gap-2">
<span>Gateway Service</span>
{checks.gateway.status === 'error' && (
<Button variant="outline" size="sm" onClick={handleStartGateway}>
Start Gateway
</Button>
)}
</div>
{renderStatus(checks.gateway.status, checks.gateway.message)}
</div>
</div>
{(checks.nodejs.status === 'error' || checks.openclaw.status === 'error') && (
<div className="mt-4 p-4 rounded-lg bg-red-900/20 border border-red-500/20">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-400 mt-0.5" />
<div>
<p className="font-medium text-red-400">Environment issue detected</p>
<p className="text-sm text-slate-300 mt-1">
Please ensure Node.js is installed and OpenClaw is properly set up.
</p>
</div>
</div>
</div>
)}
</div>
);
}
interface ProviderContentProps {
providers: Provider[];
selectedProvider: string | null;
onSelectProvider: (id: string | null) => void;
apiKey: string;
onApiKeyChange: (key: string) => void;
}
function ProviderContent({
providers,
selectedProvider,
onSelectProvider,
apiKey,
onApiKeyChange
}: ProviderContentProps) {
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [keyValid, setKeyValid] = useState<boolean | null>(null);
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
const handleValidateKey = async () => {
if (!apiKey || !selectedProvider) return;
setValidating(true);
setKeyValid(null);
try {
// Call real API validation
const result = await window.electron.ipcRenderer.invoke(
'provider:validateKey',
selectedProvider,
apiKey
) as { valid: boolean; error?: string };
setKeyValid(result.valid);
if (result.valid) {
// Save the API key to both ClawX secure storage and OpenClaw auth-profiles
try {
await window.electron.ipcRenderer.invoke(
'provider:save',
{
id: selectedProvider,
name: selectedProviderData?.name || selectedProvider,
type: selectedProvider,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
apiKey
);
} catch (saveErr) {
console.warn('Failed to persist API key:', saveErr);
}
toast.success('API key validated and saved');
} else {
toast.error(result.error || 'Invalid API key');
}
} catch (error) {
setKeyValid(false);
toast.error('Validation failed: ' + String(error));
} finally {
setValidating(false);
}
};
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2">Select AI Provider</h2>
<p className="text-slate-300">
Choose your preferred AI model provider
</p>
</div>
<div className="grid grid-cols-3 gap-4">
{providers.map((provider) => (
<button
key={provider.id}
onClick={() => {
onSelectProvider(provider.id);
setKeyValid(null);
}}
className={cn(
'p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-all text-center',
selectedProvider === provider.id && 'ring-2 ring-primary bg-white/10'
)}
>
<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>
{selectedProvider && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="apiKey"
type={showKey ? 'text' : 'password'}
placeholder={selectedProviderData?.placeholder}
value={apiKey}
onChange={(e) => {
onApiKeyChange(e.target.value);
setKeyValid(null);
}}
className="pr-10 bg-white/5 border-white/10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
onClick={handleValidateKey}
disabled={!apiKey || validating}
>
{validating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Validate'
)}
</Button>
</div>
{keyValid !== null && (
<p className={cn('text-sm', keyValid ? 'text-green-400' : 'text-red-400')}>
{keyValid ? '✓ API key is valid' : '✗ Invalid API key'}
</p>
)}
</div>
<p className="text-sm text-slate-400">
Your API key will be securely stored in the system keychain.
</p>
</motion.div>
)}
</div>
);
}
// NOTE: ChannelContent component moved to Settings > Channels page
// NOTE: SkillsContent component removed - auto-install essential skills
// Installation status for each skill
type InstallStatus = 'pending' | 'installing' | 'completed' | 'failed';
interface SkillInstallState {
id: string;
name: string;
description: string;
status: InstallStatus;
}
interface InstallingContentProps {
skills: DefaultSkill[];
onComplete: (installedSkills: string[]) => void;
}
function InstallingContent({ skills, onComplete }: InstallingContentProps) {
const [skillStates, setSkillStates] = useState<SkillInstallState[]>(
skills.map((s) => ({ ...s, status: 'pending' as InstallStatus }))
);
const [overallProgress, setOverallProgress] = useState(0);
const installStarted = useRef(false);
// Simulate installation process
useEffect(() => {
if (installStarted.current) return;
installStarted.current = true;
const installSkills = async () => {
const installedIds: string[] = [];
for (let i = 0; i < skills.length; i++) {
// Set current skill to installing
setSkillStates((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'installing' } : s
)
);
// Simulate installation time (1-2 seconds per skill)
const installTime = 1000 + Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, installTime));
// Mark as completed
setSkillStates((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'completed' } : s
)
);
installedIds.push(skills[i].id);
// Update overall progress
setOverallProgress(Math.round(((i + 1) / skills.length) * 100));
}
// Small delay before completing
await new Promise((resolve) => setTimeout(resolve, 500));
onComplete(installedIds);
};
installSkills();
}, [skills, onComplete]);
const getStatusIcon = (status: InstallStatus) => {
switch (status) {
case 'pending':
return <div className="h-5 w-5 rounded-full border-2 border-slate-500" />;
case 'installing':
return <Loader2 className="h-5 w-5 text-primary animate-spin" />;
case 'completed':
return <CheckCircle2 className="h-5 w-5 text-green-400" />;
case 'failed':
return <XCircle className="h-5 w-5 text-red-400" />;
}
};
const getStatusText = (skill: SkillInstallState) => {
switch (skill.status) {
case 'pending':
return <span className="text-slate-500">Pending</span>;
case 'installing':
return <span className="text-primary">Installing...</span>;
case 'completed':
return <span className="text-green-400">Installed</span>;
case 'failed':
return <span className="text-red-400">Failed</span>;
}
};
return (
<div className="space-y-6">
<div className="text-center">
<div className="text-4xl mb-4"></div>
<h2 className="text-xl font-semibold mb-2">Installing Essential Components</h2>
<p className="text-slate-300">
Setting up the tools needed to power your AI assistant
</p>
</div>
{/* Progress bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-400">Progress</span>
<span className="text-primary">{overallProgress}%</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${overallProgress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
{/* Skill list */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{skillStates.map((skill) => (
<motion.div
key={skill.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
'flex items-center justify-between p-3 rounded-lg',
skill.status === 'installing' ? 'bg-white/10' : 'bg-white/5'
)}
>
<div className="flex items-center gap-3">
{getStatusIcon(skill.status)}
<div>
<p className="font-medium">{skill.name}</p>
<p className="text-xs text-slate-400">{skill.description}</p>
</div>
</div>
{getStatusText(skill)}
</motion.div>
))}
</div>
<p className="text-sm text-slate-400 text-center">
This may take a few moments...
</p>
</div>
);
}
interface CompleteContentProps {
selectedProvider: string | null;
installedSkills: string[];
}
function CompleteContent({ selectedProvider, installedSkills }: CompleteContentProps) {
const gatewayStatus = useGatewayStore((state) => state.status);
const providerData = providers.find((p) => p.id === selectedProvider);
const installedSkillNames = defaultSkills
.filter((s) => installedSkills.includes(s.id))
.map((s) => s.name)
.join(', ');
return (
<div className="text-center space-y-6">
<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-3 text-left max-w-md mx-auto">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>AI Provider</span>
<span className="text-green-400">
{providerData ? `${providerData.icon} ${providerData.name}` : '—'}
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Components</span>
<span className="text-green-400">
{installedSkillNames || `${installedSkills.length} installed`}
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Gateway</span>
<span className={gatewayStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}>
{gatewayStatus.state === 'running' ? '✓ Running' : gatewayStatus.state}
</span>
</div>
</div>
<p className="text-sm text-slate-400">
You can customize skills and connect channels in Settings
</p>
</div>
);
}
export default Setup;

556
src/pages/Skills/index.tsx Normal file
View File

@@ -0,0 +1,556 @@
/**
* Skills Page
* Browse and manage AI skills
*/
import { useEffect, useState, useCallback } from 'react';
import {
Search,
Puzzle,
RefreshCw,
Lock,
Package,
Info,
X,
Settings,
CheckCircle2,
XCircle,
AlertCircle,
ChevronRight,
Sparkles,
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSkillsStore } from '@/stores/skills';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { Skill, SkillCategory, SkillBundle } 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',
};
const categoryIcons: Record<SkillCategory, string> = {
productivity: '📋',
developer: '💻',
'smart-home': '🏠',
media: '🎬',
communication: '💬',
security: '🔒',
information: '📰',
utility: '🔧',
custom: '⚡',
};
// Predefined skill bundles
const skillBundles: SkillBundle[] = [
{
id: 'productivity',
name: 'Productivity Pack',
nameZh: '效率工具包',
description: 'Essential tools for daily productivity including calendar, reminders, and notes',
descriptionZh: '日常效率必备工具,包含日历、提醒和笔记',
icon: '📋',
skills: ['calendar', 'reminders', 'notes', 'tasks', 'timer'],
recommended: true,
},
{
id: 'developer',
name: 'Developer Tools',
nameZh: '开发者工具',
description: 'Code assistance, git operations, and technical documentation lookup',
descriptionZh: '代码辅助、Git 操作和技术文档查询',
icon: '💻',
skills: ['code-assist', 'git-ops', 'docs-lookup', 'snippet-manager'],
recommended: true,
},
{
id: 'information',
name: 'Information Hub',
nameZh: '信息中心',
description: 'Stay informed with web search, news, weather, and knowledge base',
descriptionZh: '通过网页搜索、新闻、天气和知识库保持信息畅通',
icon: '📰',
skills: ['web-search', 'news', 'weather', 'wikipedia', 'translate'],
},
{
id: 'smart-home',
name: 'Smart Home',
nameZh: '智能家居',
description: 'Control your smart home devices and automation routines',
descriptionZh: '控制智能家居设备和自动化场景',
icon: '🏠',
skills: ['lights', 'thermostat', 'security-cam', 'routines'],
},
];
// Skill detail dialog component
interface SkillDetailDialogProps {
skill: Skill;
onClose: () => void;
onToggle: (enabled: boolean) => void;
}
function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) {
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={onClose}>
<Card className="w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
<CardHeader className="flex flex-row items-start justify-between">
<div className="flex items-center gap-4">
<span className="text-4xl">{skill.icon || '🔧'}</span>
<div>
<CardTitle className="flex items-center gap-2">
{skill.name}
{skill.isCore && <Lock className="h-4 w-4 text-muted-foreground" />}
</CardTitle>
<CardDescription>{categoryLabels[skill.category]}</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">{skill.description}</p>
<div className="flex flex-wrap gap-2">
{skill.version && (
<Badge variant="outline">v{skill.version}</Badge>
)}
{skill.author && (
<Badge variant="secondary">by {skill.author}</Badge>
)}
{skill.isCore && (
<Badge variant="secondary">
<Lock className="h-3 w-3 mr-1" />
Core Skill
</Badge>
)}
</div>
{skill.dependencies && skill.dependencies.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Dependencies:</p>
<div className="flex flex-wrap gap-2">
{skill.dependencies.map((dep) => (
<Badge key={dep} variant="outline">{dep}</Badge>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
{skill.enabled ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-green-600 dark:text-green-400">Enabled</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-muted-foreground">Disabled</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{skill.configurable && (
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Configure
</Button>
)}
<Switch
checked={skill.enabled}
onCheckedChange={() => onToggle(!skill.enabled)}
disabled={skill.isCore}
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// Bundle card component
interface BundleCardProps {
bundle: SkillBundle;
skills: Skill[];
onApply: () => void;
}
function BundleCard({ bundle, skills, onApply }: BundleCardProps) {
const bundleSkills = skills.filter((s) => bundle.skills.includes(s.id));
const enabledCount = bundleSkills.filter((s) => s.enabled).length;
const isFullyEnabled = bundleSkills.length > 0 && enabledCount === bundleSkills.length;
return (
<Card className={cn(
'hover:border-primary/50 transition-colors cursor-pointer',
isFullyEnabled && 'border-primary/50 bg-primary/5'
)}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">{bundle.icon}</span>
<div>
<CardTitle className="text-base flex items-center gap-2">
{bundle.name}
{bundle.recommended && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Sparkles className="h-3 w-3 mr-1" />
Recommended
</Badge>
)}
</CardTitle>
<CardDescription className="text-xs">
{enabledCount}/{bundleSkills.length} skills enabled
</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground line-clamp-2">
{bundle.description}
</p>
<div className="flex flex-wrap gap-1">
{bundleSkills.slice(0, 4).map((skill) => (
<Badge
key={skill.id}
variant={skill.enabled ? 'default' : 'outline'}
className="text-xs"
>
{skill.icon} {skill.name}
</Badge>
))}
{bundleSkills.length > 4 && (
<Badge variant="outline" className="text-xs">
+{bundleSkills.length - 4} more
</Badge>
)}
</div>
<Button
variant={isFullyEnabled ? 'secondary' : 'default'}
size="sm"
className="w-full"
onClick={onApply}
>
{isFullyEnabled ? 'Disable Bundle' : 'Enable Bundle'}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</CardContent>
</Card>
);
}
export function Skills() {
const { skills, loading, error, fetchSkills, enableSkill, disableSkill } = useSkillsStore();
const gatewayStatus = useGatewayStore((state) => state.status);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<SkillCategory | 'all'>('all');
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [activeTab, setActiveTab] = useState('all');
const isGatewayRunning = gatewayStatus.state === 'running';
// Fetch skills on mount
useEffect(() => {
if (isGatewayRunning) {
fetchSkills();
}
}, [fetchSkills, isGatewayRunning]);
// 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 with counts
const categoryStats = skills.reduce((acc, skill) => {
acc[skill.category] = (acc[skill.category] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Handle toggle
const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
try {
if (enable) {
await enableSkill(skillId);
toast.success('Skill enabled');
} else {
await disableSkill(skillId);
toast.success('Skill disabled');
}
} catch (err) {
toast.error(String(err));
}
}, [enableSkill, disableSkill]);
// Handle bundle apply
const handleBundleApply = useCallback(async (bundle: SkillBundle) => {
const bundleSkills = skills.filter((s) => bundle.skills.includes(s.id));
const allEnabled = bundleSkills.every((s) => s.enabled);
try {
for (const skill of bundleSkills) {
if (allEnabled) {
if (!skill.isCore) {
await disableSkill(skill.id);
}
} else {
if (!skill.enabled) {
await enableSkill(skill.id);
}
}
}
toast.success(allEnabled ? 'Bundle disabled' : 'Bundle enabled');
} catch {
toast.error('Failed to apply bundle');
}
}, [skills, enableSkill, disableSkill]);
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 capabilities
</p>
</div>
<Button variant="outline" onClick={fetchSkills} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Gateway Warning */}
{!isGatewayRunning && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Skills cannot be loaded without an active Gateway.
</span>
</CardContent>
</Card>
)}
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all" className="gap-2">
<Puzzle className="h-4 w-4" />
All Skills
</TabsTrigger>
<TabsTrigger value="bundles" className="gap-2">
<Package className="h-4 w-4" />
Bundles
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="space-y-6 mt-6">
{/* Search and Filter */}
<div className="flex gap-4 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<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 ({skills.length})
</Button>
{Object.entries(categoryStats).map(([category, count]) => (
<Button
key={category}
variant={selectedCategory === category ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(category as SkillCategory)}
className="gap-1"
>
<span>{categoryIcons[category as SkillCategory]}</span>
{categoryLabels[category as SkillCategory]} ({count})
</Button>
))}
</div>
</div>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{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(
'cursor-pointer hover:border-primary/50 transition-colors',
skill.enabled && 'border-primary/50 bg-primary/5'
)}
onClick={() => setSelectedSkill(skill)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{skill.icon || categoryIcons[skill.category]}</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={(checked) => {
handleToggle(skill.id, checked);
}}
disabled={skill.isCore}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
<div className="flex items-center gap-2 mt-2">
{skill.version && (
<Badge variant="outline" className="text-xs">
v{skill.version}
</Badge>
)}
{skill.configurable && (
<Badge variant="secondary" className="text-xs">
<Settings className="h-3 w-3 mr-1" />
Configurable
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="bundles" className="space-y-6 mt-6">
<p className="text-muted-foreground">
Skill bundles are pre-configured collections of skills for common use cases.
Enable a bundle to quickly set up multiple related skills at once.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{skillBundles.map((bundle) => (
<BundleCard
key={bundle.id}
bundle={bundle}
skills={skills}
onApply={() => handleBundleApply(bundle)}
/>
))}
</div>
</TabsContent>
</Tabs>
{/* Statistics */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
<span className="font-medium text-foreground">
{skills.filter((s) => s.enabled).length}
</span>
{' '}of {skills.length} skills enabled
</span>
<span className="text-muted-foreground">
<span className="font-medium text-foreground">
{skills.filter((s) => s.isCore).length}
</span>
{' '}core skills
</span>
</div>
<Button variant="ghost" size="sm" className="text-muted-foreground">
<Info className="h-4 w-4 mr-1" />
Learn about skills
</Button>
</div>
</CardContent>
</Card>
{/* Skill Detail Dialog */}
{selectedSkill && (
<SkillDetailDialog
skill={selectedSkill}
onClose={() => setSelectedSkill(null)}
onToggle={(enabled) => {
handleToggle(selectedSkill.id, enabled);
setSelectedSkill({ ...selectedSkill, enabled });
}}
/>
)}
</div>
);
}
export default Skills;

164
src/stores/channels.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* Channels State Store
* Manages messaging channel state
*/
import { create } from 'zustand';
import type { Channel, ChannelType } from '../types/channel';
interface AddChannelParams {
type: ChannelType;
name: string;
token?: string;
}
interface ChannelsState {
channels: Channel[];
loading: boolean;
error: string | null;
// Actions
fetchChannels: () => Promise<void>;
addChannel: (params: AddChannelParams) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
connectChannel: (channelId: string) => Promise<void>;
disconnectChannel: (channelId: string) => Promise<void>;
requestQrCode: (channelType: ChannelType) => Promise<{ qrCode: string; sessionId: string }>;
setChannels: (channels: Channel[]) => void;
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
clearError: () => void;
}
export const useChannelsStore = create<ChannelsState>((set, get) => ({
channels: [],
loading: false,
error: null,
fetchChannels: async () => {
// channels.status returns a complex nested object, not a simple array.
// Channel management is deferred to Settings > Channels page.
// For now, just use empty list - channels will be added later.
set({ channels: [], loading: false });
},
addChannel: async (params) => {
try {
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'channels.add',
params
) as { success: boolean; result?: Channel; error?: string };
if (result.success && result.result) {
set((state) => ({
channels: [...state.channels, result.result!],
}));
return result.result;
} else {
// If gateway is not available, create a local channel for now
const newChannel: Channel = {
id: `local-${Date.now()}`,
type: params.type,
name: params.name,
status: 'disconnected',
};
set((state) => ({
channels: [...state.channels, newChannel],
}));
return newChannel;
}
} catch {
// Create local channel if gateway unavailable
const newChannel: Channel = {
id: `local-${Date.now()}`,
type: params.type,
name: params.name,
status: 'disconnected',
};
set((state) => ({
channels: [...state.channels, newChannel],
}));
return newChannel;
}
},
deleteChannel: async (channelId) => {
try {
await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'channels.delete',
{ channelId }
);
} catch (error) {
// Continue with local deletion even if gateway fails
console.error('Failed to delete channel from gateway:', error);
}
// Remove from local state
set((state) => ({
channels: state.channels.filter((c) => c.id !== channelId),
}));
},
connectChannel: async (channelId) => {
const { updateChannel } = get();
updateChannel(channelId, { status: 'connecting', error: undefined });
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) => {
const { updateChannel } = get();
try {
await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'channels.disconnect',
{ channelId }
);
} catch (error) {
console.error('Failed to disconnect channel:', error);
}
updateChannel(channelId, { status: 'disconnected', error: undefined });
},
requestQrCode: async (channelType) => {
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'channels.requestQr',
{ type: channelType }
) as { success: boolean; result?: { qrCode: string; sessionId: string }; error?: string };
if (result.success && result.result) {
return result.result;
}
throw new Error(result.error || 'Failed to request QR code');
},
setChannels: (channels) => set({ channels }),
updateChannel: (channelId, updates) => {
set((state) => ({
channels: state.channels.map((channel) =>
channel.id === channelId ? { ...channel, ...updates } : channel
),
}));
},
clearError: () => set({ error: null }),
}));

298
src/stores/chat.ts Normal file
View File

@@ -0,0 +1,298 @@
/**
* Chat State Store
* Manages chat messages, sessions, streaming, and thinking state.
* Communicates with OpenClaw Gateway via gateway:rpc IPC.
*/
import { create } from 'zustand';
// ── Types ────────────────────────────────────────────────────────
/** Raw message from OpenClaw chat.history */
export interface RawMessage {
role: 'user' | 'assistant' | 'system' | 'toolresult';
content: unknown; // string | ContentBlock[]
timestamp?: number;
id?: string;
toolCallId?: string;
}
/** Content block inside a message */
export interface ContentBlock {
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result';
text?: string;
thinking?: string;
source?: { type: string; media_type: string; data: string };
id?: string;
name?: string;
input?: unknown;
content?: unknown;
}
/** Session from sessions.list */
export interface ChatSession {
key: string;
label?: string;
displayName?: string;
thinkingLevel?: string;
model?: string;
}
interface ChatState {
// Messages
messages: RawMessage[];
loading: boolean;
error: string | null;
// Streaming
sending: boolean;
activeRunId: string | null;
streamingText: string;
streamingMessage: unknown | null;
// Sessions
sessions: ChatSession[];
currentSessionKey: string;
// Thinking
showThinking: boolean;
thinkingLevel: string | null;
// Actions
loadSessions: () => Promise<void>;
switchSession: (key: string) => void;
newSession: () => void;
loadHistory: () => Promise<void>;
sendMessage: (text: string) => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void;
toggleThinking: () => void;
refresh: () => Promise<void>;
clearError: () => void;
}
// ── Store ────────────────────────────────────────────────────────
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
loading: false,
error: null,
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
sessions: [],
currentSessionKey: 'main',
showThinking: true,
thinkingLevel: null,
// ── Load sessions via sessions.list ──
loadSessions: async () => {
try {
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'sessions.list',
{ limit: 50 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
})).filter((s: ChatSession) => s.key);
set({ sessions });
}
} catch (err) {
console.warn('Failed to load sessions:', err);
}
},
// ── Switch session ──
switchSession: (key: string) => {
set({
currentSessionKey: key,
messages: [],
streamingText: '',
streamingMessage: null,
activeRunId: null,
error: null,
});
// Load history for new session
get().loadHistory();
},
// ── New session ──
newSession: () => {
// Generate a new unique session key and switch to it
const newKey = `session-${Date.now()}`;
set({
currentSessionKey: newKey,
messages: [],
streamingText: '',
streamingMessage: null,
activeRunId: null,
error: null,
});
// Reload sessions list to include the new one after first message
get().loadSessions();
},
// ── Load chat history ──
loadHistory: async () => {
const { currentSessionKey } = get();
set({ loading: true, error: null });
try {
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
set({ messages: rawMessages, thinkingLevel, loading: false });
} else {
set({ messages: [], loading: false });
}
} catch (err) {
console.warn('Failed to load chat history:', err);
set({ messages: [], loading: false });
}
},
// ── Send message ──
sendMessage: async (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const { currentSessionKey } = get();
// Add user message optimistically
const userMsg: RawMessage = {
role: 'user',
content: trimmed,
timestamp: Date.now() / 1000,
id: crypto.randomUUID(),
};
set((s) => ({
messages: [...s.messages, userMsg],
sending: true,
error: null,
streamingText: '',
streamingMessage: null,
}));
try {
const idempotencyKey = crypto.randomUUID();
const result = await window.electron.ipcRenderer.invoke(
'gateway:rpc',
'chat.send',
{
sessionKey: currentSessionKey,
message: trimmed,
deliver: false,
idempotencyKey,
}
) as { success: boolean; result?: { runId?: string }; error?: string };
if (!result.success) {
set({ error: result.error || 'Failed to send message', sending: false });
} else if (result.result?.runId) {
set({ activeRunId: result.result.runId });
}
} catch (err) {
set({ error: String(err), sending: false });
}
},
// ── Handle incoming chat events from Gateway ──
handleChatEvent: (event: Record<string, unknown>) => {
const runId = String(event.runId || '');
const eventState = String(event.state || '');
const { activeRunId } = get();
// Only process events for the active run (or if no active run set)
if (activeRunId && runId && runId !== activeRunId) return;
switch (eventState) {
case 'delta': {
// Streaming update - store the cumulative message
set({
streamingMessage: event.message ?? get().streamingMessage,
});
break;
}
case 'final': {
// Message complete - add to history and clear streaming
const finalMsg = event.message as RawMessage | undefined;
if (finalMsg) {
set((s) => ({
messages: [...s.messages, {
...finalMsg,
role: finalMsg.role || 'assistant',
id: finalMsg.id || `run-${runId}`,
}],
streamingText: '',
streamingMessage: null,
sending: false,
activeRunId: null,
}));
} else {
// No message in final event - reload history to get complete data
set({ streamingText: '', streamingMessage: null, sending: false, activeRunId: null });
get().loadHistory();
}
break;
}
case 'error': {
const errorMsg = String(event.errorMessage || 'An error occurred');
set({
error: errorMsg,
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
});
break;
}
case 'aborted': {
set({
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
});
break;
}
}
},
// ── Toggle thinking visibility ──
toggleThinking: () => set((s) => ({ showThinking: !s.showThinking })),
// ── Refresh: reload history + sessions ──
refresh: async () => {
const { loadHistory, loadSessions } = get();
await Promise.all([loadHistory(), loadSessions()]);
},
clearError: () => set({ error: null }),
}));

100
src/stores/cron.ts Normal file
View 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) => ({
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 }),
}));

174
src/stores/gateway.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* Gateway State Store
* Manages Gateway connection state and communication
*/
import { create } from 'zustand';
import type { GatewayStatus } from '../types/gateway';
interface GatewayHealth {
ok: boolean;
error?: string;
uptime?: number;
}
interface GatewayState {
status: GatewayStatus;
health: GatewayHealth | null;
isInitialized: boolean;
lastError: string | null;
// Actions
init: () => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
restart: () => Promise<void>;
checkHealth: () => Promise<GatewayHealth>;
rpc: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
setStatus: (status: GatewayStatus) => void;
clearError: () => void;
}
export const useGatewayStore = create<GatewayState>((set, get) => ({
status: {
state: 'stopped',
port: 18789,
},
health: null,
isInitialized: false,
lastError: null,
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 });
});
// Listen for errors
window.electron.ipcRenderer.on('gateway:error', (error) => {
set({ lastError: String(error) });
});
// Listen for notifications
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
console.log('Gateway notification:', notification);
});
// Listen for chat events from the gateway and forward to chat store
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
try {
// Dynamic import to avoid circular dependency
import('./chat').then(({ useChatStore }) => {
const chatData = data as { message?: Record<string, unknown> } | Record<string, unknown>;
const event = ('message' in chatData && typeof chatData.message === 'object')
? chatData.message as Record<string, unknown>
: chatData as Record<string, unknown>;
useChatStore.getState().handleChatEvent(event);
});
} catch (err) {
console.warn('Failed to forward chat event:', err);
}
});
} catch (error) {
console.error('Failed to initialize Gateway:', error);
set({ lastError: String(error) });
}
},
start: async () => {
try {
set({ status: { ...get().status, state: 'starting' }, lastError: null });
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 },
lastError: result.error || 'Failed to start Gateway'
});
}
} catch (error) {
set({
status: { ...get().status, state: 'error', error: String(error) },
lastError: String(error)
});
}
},
stop: async () => {
try {
await window.electron.ipcRenderer.invoke('gateway:stop');
set({ status: { ...get().status, state: 'stopped' }, lastError: null });
} catch (error) {
console.error('Failed to stop Gateway:', error);
set({ lastError: String(error) });
}
},
restart: async () => {
try {
set({ status: { ...get().status, state: 'starting' }, lastError: null });
const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string };
if (!result.success) {
set({
status: { ...get().status, state: 'error', error: result.error },
lastError: result.error || 'Failed to restart Gateway'
});
}
} catch (error) {
set({
status: { ...get().status, state: 'error', error: String(error) },
lastError: String(error)
});
}
},
checkHealth: async () => {
try {
const result = await window.electron.ipcRenderer.invoke('gateway:health') as {
success: boolean;
ok: boolean;
error?: string;
uptime?: number
};
const health: GatewayHealth = {
ok: result.ok,
error: result.error,
uptime: result.uptime,
};
set({ health });
return health;
} catch (error) {
const health: GatewayHealth = { ok: false, error: String(error) };
set({ health });
return health;
}
},
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as {
success: boolean;
result?: T;
error?: string;
};
if (!result.success) {
throw new Error(result.error || `RPC call failed: ${method}`);
}
return result.result as T;
},
setStatus: (status) => set({ status }),
clearError: () => set({ lastError: null }),
}));

198
src/stores/providers.ts Normal file
View File

@@ -0,0 +1,198 @@
/**
* Provider State Store
* Manages AI provider configurations
*/
import { create } from 'zustand';
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'ollama' | 'custom';
baseUrl?: string;
model?: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/**
* Provider with key info (for display)
*/
export interface ProviderWithKeyInfo extends ProviderConfig {
hasKey: boolean;
keyMasked: string | null;
}
interface ProviderState {
providers: ProviderWithKeyInfo[];
defaultProviderId: string | null;
loading: boolean;
error: string | null;
// Actions
fetchProviders: () => Promise<void>;
addProvider: (config: Omit<ProviderConfig, 'createdAt' | 'updatedAt'>, apiKey?: string) => Promise<void>;
updateProvider: (providerId: string, updates: Partial<ProviderConfig>, apiKey?: string) => Promise<void>;
deleteProvider: (providerId: string) => Promise<void>;
setApiKey: (providerId: string, apiKey: string) => Promise<void>;
deleteApiKey: (providerId: string) => Promise<void>;
setDefaultProvider: (providerId: string) => Promise<void>;
validateApiKey: (providerId: string, apiKey: string) => Promise<{ valid: boolean; error?: string }>;
getApiKey: (providerId: string) => Promise<string | null>;
}
export const useProviderStore = create<ProviderState>((set, get) => ({
providers: [],
defaultProviderId: null,
loading: false,
error: null,
fetchProviders: async () => {
set({ loading: true, error: null });
try {
const providers = await window.electron.ipcRenderer.invoke('provider:list') as ProviderWithKeyInfo[];
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
set({
providers,
defaultProviderId: defaultId,
loading: false
});
} catch (error) {
set({ error: String(error), loading: false });
}
},
addProvider: async (config, apiKey) => {
try {
const fullConfig: ProviderConfig = {
...config,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const result = await window.electron.ipcRenderer.invoke('provider:save', fullConfig, apiKey) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to save provider');
}
// Refresh the list
await get().fetchProviders();
} catch (error) {
console.error('Failed to add provider:', error);
throw error;
}
},
updateProvider: async (providerId, updates, apiKey) => {
try {
const existing = get().providers.find((p) => p.id === providerId);
if (!existing) {
throw new Error('Provider not found');
}
const updatedConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const result = await window.electron.ipcRenderer.invoke('provider:save', updatedConfig, apiKey) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to update provider');
}
// Refresh the list
await get().fetchProviders();
} catch (error) {
console.error('Failed to update provider:', error);
throw error;
}
},
deleteProvider: async (providerId) => {
try {
const result = await window.electron.ipcRenderer.invoke('provider:delete', providerId) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to delete provider');
}
// Refresh the list
await get().fetchProviders();
} catch (error) {
console.error('Failed to delete provider:', error);
throw error;
}
},
setApiKey: async (providerId, apiKey) => {
try {
const result = await window.electron.ipcRenderer.invoke('provider:setApiKey', providerId, apiKey) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to set API key');
}
// Refresh the list
await get().fetchProviders();
} catch (error) {
console.error('Failed to set API key:', error);
throw error;
}
},
deleteApiKey: async (providerId) => {
try {
const result = await window.electron.ipcRenderer.invoke('provider:deleteApiKey', providerId) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to delete API key');
}
// Refresh the list
await get().fetchProviders();
} catch (error) {
console.error('Failed to delete API key:', error);
throw error;
}
},
setDefaultProvider: async (providerId) => {
try {
const result = await window.electron.ipcRenderer.invoke('provider:setDefault', providerId) as { success: boolean; error?: string };
if (!result.success) {
throw new Error(result.error || 'Failed to set default provider');
}
set({ defaultProviderId: providerId });
} catch (error) {
console.error('Failed to set default provider:', error);
throw error;
}
},
validateApiKey: async (providerId, apiKey) => {
try {
const result = await window.electron.ipcRenderer.invoke('provider:validateKey', providerId, apiKey) as { valid: boolean; error?: string };
return result;
} catch (error) {
return { valid: false, error: String(error) };
}
},
getApiKey: async (providerId) => {
try {
return await window.electron.ipcRenderer.invoke('provider:getApiKey', providerId) as string | null;
} catch {
return null;
}
},
}));

88
src/stores/settings.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* 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;
// Setup
setupComplete: 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;
markSetupComplete: () => 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,
setupComplete: 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 }),
markSetupComplete: () => set({ setupComplete: true }),
resetSettings: () => set(defaultSettings),
}),
{
name: 'clawx-settings',
}
)
);

90
src/stores/skills.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* 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 () => {
// skills.status returns a complex nested object, not a simple Skill[] array.
// Skill management is handled in the Skills page.
// For now, use empty list - will be properly integrated later.
set({ skills: [], 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
),
}));
},
}));

184
src/stores/update.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Update State Store
* Manages application update state
*/
import { create } from 'zustand';
export interface UpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string | null;
}
export interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'downloaded'
| 'error';
interface UpdateState {
status: UpdateStatus;
currentVersion: string;
updateInfo: UpdateInfo | null;
progress: ProgressInfo | null;
error: string | null;
isInitialized: boolean;
// Actions
init: () => Promise<void>;
checkForUpdates: () => Promise<void>;
downloadUpdate: () => Promise<void>;
installUpdate: () => void;
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
setAutoDownload: (enable: boolean) => Promise<void>;
clearError: () => void;
}
export const useUpdateStore = create<UpdateState>((set, get) => ({
status: 'idle',
currentVersion: '0.0.0',
updateInfo: null,
progress: null,
error: null,
isInitialized: false,
init: async () => {
if (get().isInitialized) return;
// Get current version
try {
const version = await window.electron.ipcRenderer.invoke('update:version');
set({ currentVersion: version as string });
} catch (error) {
console.error('Failed to get version:', error);
}
// Get current status
try {
const status = await window.electron.ipcRenderer.invoke('update:status') as {
status: UpdateStatus;
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
};
set({
status: status.status,
updateInfo: status.info || null,
progress: status.progress || null,
error: status.error || null,
});
} catch (error) {
console.error('Failed to get update status:', error);
}
// Listen for update events
window.electron.ipcRenderer.on('update:status-changed', (data) => {
const status = data as {
status: UpdateStatus;
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
};
set({
status: status.status,
updateInfo: status.info || null,
progress: status.progress || null,
error: status.error || null,
});
});
window.electron.ipcRenderer.on('update:checking', () => {
set({ status: 'checking', error: null });
});
window.electron.ipcRenderer.on('update:available', (info) => {
set({ status: 'available', updateInfo: info as UpdateInfo });
});
window.electron.ipcRenderer.on('update:not-available', () => {
set({ status: 'not-available' });
});
window.electron.ipcRenderer.on('update:progress', (progress) => {
set({ status: 'downloading', progress: progress as ProgressInfo });
});
window.electron.ipcRenderer.on('update:downloaded', (info) => {
set({ status: 'downloaded', updateInfo: info as UpdateInfo, progress: null });
});
window.electron.ipcRenderer.on('update:error', (error) => {
set({ status: 'error', error: error as string, progress: null });
});
set({ isInitialized: true });
},
checkForUpdates: async () => {
set({ status: 'checking', error: null });
try {
const result = await window.electron.ipcRenderer.invoke('update:check') as {
success: boolean;
info?: UpdateInfo;
error?: string;
};
if (!result.success) {
set({ status: 'error', error: result.error || 'Failed to check for updates' });
}
} catch (error) {
set({ status: 'error', error: String(error) });
}
},
downloadUpdate: async () => {
set({ status: 'downloading', error: null });
try {
const result = await window.electron.ipcRenderer.invoke('update:download') as {
success: boolean;
error?: string;
};
if (!result.success) {
set({ status: 'error', error: result.error || 'Failed to download update' });
}
} catch (error) {
set({ status: 'error', error: String(error) });
}
},
installUpdate: () => {
window.electron.ipcRenderer.invoke('update:install');
},
setChannel: async (channel) => {
try {
await window.electron.ipcRenderer.invoke('update:setChannel', channel);
} catch (error) {
console.error('Failed to set update channel:', error);
}
},
setAutoDownload: async (enable) => {
try {
await window.electron.ipcRenderer.invoke('update:setAutoDownload', enable);
} catch (error) {
console.error('Failed to set auto-download:', error);
}
},
clearError: () => set({ error: null, status: 'idle' }),
}));

160
src/styles/globals.css Normal file
View File

@@ -0,0 +1,160 @@
@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;
}
/* Prose styling for markdown */
.prose {
line-height: 1.6;
}
.prose p {
margin-bottom: 0.75em;
}
.prose p:last-child {
margin-bottom: 0;
}
.prose ul,
.prose ol {
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1.5em;
}
.prose li {
margin-bottom: 0.25em;
}
.prose pre {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1em;
margin: 0.5em 0;
color: hsl(var(--muted-foreground));
}
.prose table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
}
.prose th,
.prose td {
border: 1px solid hsl(var(--border));
padding: 0.5em;
text-align: left;
}
.prose th {
background: hsl(var(--muted));
}
/* Typing indicator animation */
@keyframes bounce {
0%, 60%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
}

74
src/types/channel.ts Normal file
View 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
View 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
View 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 {};

58
src/types/gateway.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Gateway Type Definitions
* Types for Gateway communication and data structures
*/
/**
* Gateway connection status
*/
export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
port: number;
pid?: number;
uptime?: number;
error?: string;
connectedAt?: number;
version?: string;
reconnectAttempts?: number;
}
/**
* Gateway RPC response
*/
export interface GatewayRpcResponse<T = unknown> {
success: boolean;
result?: T;
error?: string;
}
/**
* Gateway health check response
*/
export interface GatewayHealth {
ok: boolean;
error?: string;
uptime?: number;
version?: string;
}
/**
* Gateway notification (server-initiated event)
*/
export interface GatewayNotification {
method: string;
params?: unknown;
}
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
apiKey?: string;
baseUrl?: string;
model?: string;
enabled: boolean;
}

64
src/types/skill.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,75 @@
/**
* Zustand Stores Tests
*/
import { describe, it, expect, beforeEach } 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
View File

@@ -0,0 +1,45 @@
/**
* Utility Functions Tests
*/
import { describe, it, expect } from 'vitest';
import { cn, 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', 'active')).toBe('base active');
expect(cn('base', false)).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
View 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" }]
}

31
tsconfig.node.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"composite": true,
"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
View 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
View 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'),
},
},
});