feat(core): initialize project skeleton with Electron + React + TypeScript

Set up the complete project foundation for ClawX, a graphical AI assistant:

- Electron main process with IPC handlers, menu, tray, and gateway management
- React renderer with routing, layout components, and page scaffolding
- Zustand state management for gateway, settings, channels, skills, chat, and cron
- shadcn/ui components with Tailwind CSS and CSS variable theming
- Build tooling with Vite, electron-builder, and TypeScript configuration
- Testing setup with Vitest and Playwright
- Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
Haze
2026-02-05 23:09:17 +08:00
Unverified
parent 9442e5f77a
commit b8ab0208d0
71 changed files with 14086 additions and 3 deletions

View File

@@ -0,0 +1,164 @@
/**
* Channels Page
* Manage messaging channel connections
*/
import { useEffect } from 'react';
import { Plus, Radio, RefreshCw, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useChannelsStore } from '@/stores/channels';
import { StatusBadge } from '@/components/common/StatusBadge';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
export function Channels() {
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel } = useChannelsStore();
// Fetch channels on mount
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
// Supported channel types for adding
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Channels</h1>
<p className="text-muted-foreground">
Connect and manage your messaging channels
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Channel
</Button>
</div>
</div>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive">
{error}
</CardContent>
</Card>
)}
{/* Channels Grid */}
{channels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Radio className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No channels configured</h3>
<p className="text-muted-foreground text-center mb-4">
Connect a messaging channel to start using ClawX
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Your First Channel
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
<Card key={channel.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">
{CHANNEL_ICONS[channel.type]}
</span>
<div>
<CardTitle className="text-lg">{channel.name}</CardTitle>
<CardDescription>
{CHANNEL_NAMES[channel.type]}
</CardDescription>
</div>
</div>
<StatusBadge status={channel.status} />
</div>
</CardHeader>
<CardContent>
{channel.lastActivity && (
<p className="text-sm text-muted-foreground mb-4">
Last activity: {new Date(channel.lastActivity).toLocaleString()}
</p>
)}
{channel.error && (
<p className="text-sm text-destructive mb-4">{channel.error}</p>
)}
<div className="flex gap-2">
{channel.status === 'connected' ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnectChannel(channel.id)}
>
Disconnect
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => connectChannel(channel.id)}
disabled={channel.status === 'connecting'}
>
{channel.status === 'connecting' ? 'Connecting...' : 'Connect'}
</Button>
)}
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add Channel Types */}
<Card>
<CardHeader>
<CardTitle>Supported Channels</CardTitle>
<CardDescription>
Click on a channel type to add it
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{supportedTypes.map((type) => (
<Button
key={type}
variant="outline"
className="h-auto flex-col gap-2 py-4"
>
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
<span>{CHANNEL_NAMES[type]}</span>
</Button>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export default Channels;

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

@@ -0,0 +1,166 @@
/**
* Chat Page
* Conversation interface with AI
*/
import { useState, useEffect, useRef } from 'react';
import { Send, Trash2, Bot, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { useChatStore } from '@/stores/chat';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn, formatRelativeTime } from '@/lib/utils';
export function Chat() {
const { messages, loading, sending, fetchHistory, sendMessage, clearHistory } = useChatStore();
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// Fetch history on mount
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Handle send message
const handleSend = async () => {
if (!input.trim() || sending) return;
const content = input.trim();
setInput('');
await sendMessage(content);
};
// Handle key press
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
{/* Messages Area */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="lg" />
</div>
) : messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="h-16 w-16 mb-4 opacity-50" />
<h3 className="text-lg font-medium">No messages yet</h3>
<p className="text-sm">Start a conversation with your AI assistant</p>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={cn(
'flex gap-3',
message.role === 'user' ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Avatar */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
{message.role === 'user' ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
{/* Message Content */}
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<p
className={cn(
'mt-1 text-xs',
message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{formatRelativeTime(message.timestamp)}
</p>
{/* Tool Calls */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-2 space-y-2">
{message.toolCalls.map((tool) => (
<Card key={tool.id} className="bg-background/50">
<CardContent className="p-2">
<p className="text-xs font-medium">
Tool: {tool.name}
</p>
<p className="text-xs text-muted-foreground">
Status: {tool.status}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="border-t p-4">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={clearHistory}
disabled={messages.length === 0}
>
<Trash2 className="h-4 w-4" />
</Button>
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
disabled={sending}
className="flex-1"
/>
<Button onClick={handleSend} disabled={!input.trim() || sending}>
{sending ? (
<LoadingSpinner size="sm" className="text-primary-foreground" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}
export default Chat;

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

@@ -0,0 +1,215 @@
/**
* Cron Page
* Manage scheduled tasks
*/
import { useEffect, useState } from 'react';
import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { useCronStore } from '@/stores/cron';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { formatRelativeTime } from '@/lib/utils';
import type { CronJob } from '@/types/cron';
export function Cron() {
const { jobs, loading, error, fetchJobs, toggleJob, deleteJob, triggerJob } = useCronStore();
// Fetch jobs on mount
useEffect(() => {
fetchJobs();
}, [fetchJobs]);
// Statistics
const activeJobs = jobs.filter((j) => j.enabled);
const pausedJobs = jobs.filter((j) => !j.enabled);
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Cron Tasks</h1>
<p className="text-muted-foreground">
Schedule automated AI tasks
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchJobs}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Task
</Button>
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-primary/10 p-3">
<Clock className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{jobs.length}</p>
<p className="text-sm text-muted-foreground">Total Tasks</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
<Play className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold">{activeJobs.length}</p>
<p className="text-sm text-muted-foreground">Running</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-yellow-100 p-3 dark:bg-yellow-900">
<Pause className="h-6 w-6 text-yellow-600" />
</div>
<div>
<p className="text-2xl font-bold">{pausedJobs.length}</p>
<p className="text-sm text-muted-foreground">Paused</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive">
{error}
</CardContent>
</Card>
)}
{/* Jobs List */}
{jobs.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No scheduled tasks</h3>
<p className="text-muted-foreground text-center mb-4">
Create your first scheduled task to automate AI workflows
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Task
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{jobs.map((job) => (
<CronJobCard
key={job.id}
job={job}
onToggle={(enabled) => toggleJob(job.id, enabled)}
onDelete={() => deleteJob(job.id)}
onTrigger={() => triggerJob(job.id)}
/>
))}
</div>
)}
</div>
);
}
interface CronJobCardProps {
job: CronJob;
onToggle: (enabled: boolean) => void;
onDelete: () => void;
onTrigger: () => void;
}
function CronJobCard({ job, onToggle, onDelete, onTrigger }: CronJobCardProps) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">📋</span>
<div>
<CardTitle className="text-lg">{job.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Clock className="h-3 w-3" />
{job.schedule}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={job.enabled ? 'success' : 'secondary'}>
{job.enabled ? 'Active' : 'Paused'}
</Badge>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
/>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">
{job.message}
</p>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>
Target: {job.target.channelName}
</span>
{job.lastRun && (
<span>
Last run: {formatRelativeTime(job.lastRun.time)}
{job.lastRun.success ? ' ✓' : ' ✗'}
</span>
)}
{job.nextRun && (
<span>
Next: {new Date(job.nextRun).toLocaleString()}
</span>
)}
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={onTrigger}>
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
export default Cron;

View File

@@ -0,0 +1,251 @@
/**
* Dashboard Page
* Main overview page showing system status and quick actions
*/
import { useEffect } from 'react';
import {
Activity,
MessageSquare,
Radio,
Puzzle,
Clock,
Settings,
Plus,
RefreshCw,
ExternalLink,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useGatewayStore } from '@/stores/gateway';
import { useChannelsStore } from '@/stores/channels';
import { useSkillsStore } from '@/stores/skills';
import { StatusBadge } from '@/components/common/StatusBadge';
import { formatRelativeTime } from '@/lib/utils';
export function Dashboard() {
const gatewayStatus = useGatewayStore((state) => state.status);
const { channels, fetchChannels } = useChannelsStore();
const { skills, fetchSkills } = useSkillsStore();
// Fetch data on mount
useEffect(() => {
fetchChannels();
fetchSkills();
}, [fetchChannels, fetchSkills]);
// Calculate statistics
const connectedChannels = channels.filter((c) => c.status === 'connected').length;
const enabledSkills = skills.filter((s) => s.enabled).length;
// Calculate uptime
const uptime = gatewayStatus.connectedAt
? Math.floor((Date.now() - gatewayStatus.connectedAt) / 1000)
: 0;
return (
<div className="space-y-6">
{/* Status Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Gateway Status */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Gateway</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<StatusBadge status={gatewayStatus.state} />
</div>
{gatewayStatus.state === 'running' && (
<p className="mt-1 text-xs text-muted-foreground">
Port: {gatewayStatus.port} | PID: {gatewayStatus.pid || 'N/A'}
</p>
)}
</CardContent>
</Card>
{/* Channels */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Channels</CardTitle>
<Radio className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{connectedChannels}</div>
<p className="text-xs text-muted-foreground">
{connectedChannels} of {channels.length} connected
</p>
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Skills</CardTitle>
<Puzzle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{enabledSkills}</div>
<p className="text-xs text-muted-foreground">
{enabledSkills} of {skills.length} enabled
</p>
</CardContent>
</Card>
{/* Uptime */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{uptime > 0 ? formatUptime(uptime) : '—'}
</div>
<p className="text-xs text-muted-foreground">
{gatewayStatus.state === 'running' ? 'Since last restart' : 'Gateway not running'}
</p>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks and shortcuts</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/channels">
<Plus className="h-5 w-5" />
<span>Add Channel</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/skills">
<Puzzle className="h-5 w-5" />
<span>Browse Skills</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/chat">
<MessageSquare className="h-5 w-5" />
<span>Open Chat</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/settings">
<Settings className="h-5 w-5" />
<span>Settings</span>
</Link>
</Button>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Connected Channels */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Connected Channels</CardTitle>
</CardHeader>
<CardContent>
{channels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No channels configured</p>
<Button variant="link" asChild className="mt-2">
<Link to="/channels">Add your first channel</Link>
</Button>
</div>
) : (
<div className="space-y-3">
{channels.slice(0, 5).map((channel) => (
<div
key={channel.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<span className="text-lg">
{channel.type === 'whatsapp' && '📱'}
{channel.type === 'telegram' && '✈️'}
{channel.type === 'discord' && '🎮'}
{channel.type === 'slack' && '💼'}
</span>
<div>
<p className="font-medium">{channel.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{channel.type}
</p>
</div>
</div>
<StatusBadge status={channel.status} />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Enabled Skills */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Active Skills</CardTitle>
</CardHeader>
<CardContent>
{skills.filter((s) => s.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No skills enabled</p>
<Button variant="link" asChild className="mt-2">
<Link to="/skills">Enable some skills</Link>
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
{skills
.filter((s) => s.enabled)
.slice(0, 12)
.map((skill) => (
<Badge key={skill.id} variant="secondary">
{skill.icon && <span className="mr-1">{skill.icon}</span>}
{skill.name}
</Badge>
))}
{skills.filter((s) => s.enabled).length > 12 && (
<Badge variant="outline">
+{skills.filter((s) => s.enabled).length - 12} more
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
/**
* Format uptime in human-readable format
*/
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
export default Dashboard;

View File

@@ -0,0 +1,282 @@
/**
* Settings Page
* Application configuration
*/
import { useState, useEffect } from 'react';
import {
Sun,
Moon,
Monitor,
RefreshCw,
Loader2,
Terminal,
ExternalLink,
Info,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useSettingsStore } from '@/stores/settings';
import { useGatewayStore } from '@/stores/gateway';
export function Settings() {
const {
theme,
setTheme,
gatewayAutoStart,
setGatewayAutoStart,
autoCheckUpdate,
setAutoCheckUpdate,
autoDownloadUpdate,
setAutoDownloadUpdate,
devModeUnlocked,
} = useSettingsStore();
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
const [appVersion, setAppVersion] = useState('0.1.0');
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Get app version
useEffect(() => {
window.electron.ipcRenderer.invoke('app:version').then((version) => {
setAppVersion(version as string);
});
}, []);
// Check for updates
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
await window.electron.ipcRenderer.invoke('update:check');
} finally {
setCheckingUpdate(false);
}
};
// Open developer console
const openDevConsole = () => {
window.electron.openExternal('http://localhost:18789');
};
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Configure your ClawX experience
</p>
</div>
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Theme</Label>
<div className="flex gap-2">
<Button
variant={theme === 'light' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('light')}
>
<Sun className="h-4 w-4 mr-2" />
Light
</Button>
<Button
variant={theme === 'dark' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('dark')}
>
<Moon className="h-4 w-4 mr-2" />
Dark
</Button>
<Button
variant={theme === 'system' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('system')}
>
<Monitor className="h-4 w-4 mr-2" />
System
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Gateway */}
<Card>
<CardHeader>
<CardTitle>Gateway</CardTitle>
<CardDescription>OpenClaw Gateway settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Status</Label>
<p className="text-sm text-muted-foreground">
Port: {gatewayStatus.port}
</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant={
gatewayStatus.state === 'running'
? 'success'
: gatewayStatus.state === 'error'
? 'destructive'
: 'secondary'
}
>
{gatewayStatus.state}
</Badge>
<Button variant="outline" size="sm" onClick={restartGateway}>
<RefreshCw className="h-4 w-4 mr-2" />
Restart
</Button>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Auto-start Gateway</Label>
<p className="text-sm text-muted-foreground">
Start Gateway when ClawX launches
</p>
</div>
<Switch
checked={gatewayAutoStart}
onCheckedChange={setGatewayAutoStart}
/>
</div>
</CardContent>
</Card>
{/* Updates */}
<Card>
<CardHeader>
<CardTitle>Updates</CardTitle>
<CardDescription>Keep ClawX up to date</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border">
<div>
<p className="font-medium">ClawX</p>
<p className="text-sm text-muted-foreground">
Version {appVersion}
</p>
</div>
<Button
variant="outline"
onClick={handleCheckUpdate}
disabled={checkingUpdate}
>
{checkingUpdate ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</>
) : (
'Check for Updates'
)}
</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Auto-check for updates</Label>
<p className="text-sm text-muted-foreground">
Check for updates on startup
</p>
</div>
<Switch
checked={autoCheckUpdate}
onCheckedChange={setAutoCheckUpdate}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Auto-download updates</Label>
<p className="text-sm text-muted-foreground">
Download updates in the background
</p>
</div>
<Switch
checked={autoDownloadUpdate}
onCheckedChange={setAutoDownloadUpdate}
/>
</div>
</CardContent>
</Card>
{/* Developer */}
{devModeUnlocked && (
<Card>
<CardHeader>
<CardTitle>Developer</CardTitle>
<CardDescription>Advanced options for developers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>OpenClaw Console</Label>
<p className="text-sm text-muted-foreground">
Access the native OpenClaw management interface
</p>
<Button variant="outline" onClick={openDevConsole}>
<Terminal className="h-4 w-4 mr-2" />
Open Developer Console
<ExternalLink className="h-3 w-3 ml-2" />
</Button>
<p className="text-xs text-muted-foreground">
Opens http://localhost:18789 in your browser
</p>
</div>
</CardContent>
</Card>
)}
{/* About */}
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>ClawX</strong> - Graphical AI Assistant
</p>
<p>Based on OpenClaw</p>
<p>Version {appVersion}</p>
<div className="flex gap-4 pt-2">
<Button
variant="link"
className="h-auto p-0"
onClick={() => window.electron.openExternal('https://docs.clawx.app')}
>
Documentation
</Button>
<Button
variant="link"
className="h-auto p-0"
onClick={() => window.electron.openExternal('https://github.com/clawx/clawx')}
>
GitHub
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export default Settings;

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

@@ -0,0 +1,323 @@
/**
* Setup Wizard Page
* First-time setup experience for new users
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface SetupStep {
id: string;
title: string;
description: string;
}
const steps: SetupStep[] = [
{
id: 'welcome',
title: 'Welcome to ClawX',
description: 'Your AI assistant is ready to be configured',
},
{
id: 'runtime',
title: 'Environment Check',
description: 'Verifying system requirements',
},
{
id: 'provider',
title: 'AI Provider',
description: 'Configure your AI service',
},
{
id: 'channel',
title: 'Connect Channel',
description: 'Link a messaging app',
},
{
id: 'skills',
title: 'Choose Skills',
description: 'Select your skill bundles',
},
{
id: 'complete',
title: 'All Set!',
description: 'ClawX is ready to use',
},
];
export function Setup() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(0);
const step = steps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const handleNext = () => {
if (isLastStep) {
// Complete setup and go to dashboard
navigate('/');
} else {
setCurrentStep((i) => i + 1);
}
};
const handleBack = () => {
setCurrentStep((i) => Math.max(i - 1, 0));
};
const handleSkip = () => {
navigate('/');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
{/* Progress Indicator */}
<div className="flex justify-center pt-8">
<div className="flex items-center gap-2">
{steps.map((s, i) => (
<div key={s.id} className="flex items-center">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
i < currentStep
? 'border-primary bg-primary text-primary-foreground'
: i === currentStep
? 'border-primary text-primary'
: 'border-slate-600 text-slate-600'
)}
>
{i < currentStep ? (
<Check className="h-4 w-4" />
) : (
<span className="text-sm">{i + 1}</span>
)}
</div>
{i < steps.length - 1 && (
<div
className={cn(
'h-0.5 w-8 transition-colors',
i < currentStep ? 'bg-primary' : 'bg-slate-600'
)}
/>
)}
</div>
))}
</div>
</div>
{/* Step Content */}
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="mx-auto max-w-2xl p-8"
>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">{step.title}</h1>
<p className="text-slate-400">{step.description}</p>
</div>
{/* Step-specific content */}
<div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8">
{currentStep === 0 && <WelcomeContent />}
{currentStep === 1 && <RuntimeContent />}
{currentStep === 2 && <ProviderContent />}
{currentStep === 3 && <ChannelContent />}
{currentStep === 4 && <SkillsContent />}
{currentStep === 5 && <CompleteContent />}
</div>
{/* Navigation */}
<div className="flex justify-between">
<div>
{!isFirstStep && (
<Button variant="ghost" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
</div>
<div className="flex gap-2">
{!isLastStep && (
<Button variant="ghost" onClick={handleSkip}>
Skip Setup
</Button>
)}
<Button onClick={handleNext}>
{isLastStep ? (
'Get Started'
) : (
<>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
);
}
// Step content components (simplified versions)
function WelcomeContent() {
return (
<div className="text-center space-y-4">
<div className="text-6xl mb-4">🤖</div>
<h2 className="text-xl font-semibold">Welcome to ClawX</h2>
<p className="text-slate-300">
ClawX is a graphical interface for OpenClaw, making it easy to use AI
assistants across your favorite messaging platforms.
</p>
<ul className="text-left space-y-2 text-slate-300">
<li> Zero command-line required</li>
<li> Modern, beautiful interface</li>
<li> Pre-installed skill bundles</li>
<li> Cross-platform support</li>
</ul>
</div>
);
}
function RuntimeContent() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Checking Environment</h2>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Node.js Runtime</span>
<span className="text-green-400"> Installed</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>OpenClaw Package</span>
<span className="text-green-400"> Ready</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Gateway Service</span>
<span className="text-green-400"> Running</span>
</div>
</div>
</div>
);
}
function ProviderContent() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Select AI Provider</h2>
<p className="text-slate-300">
Choose your preferred AI model provider
</p>
<div className="grid grid-cols-3 gap-4">
{[
{ id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖' },
{ id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚' },
{ id: 'google', name: 'Google', model: 'Gemini', icon: '🔷' },
].map((provider) => (
<button
key={provider.id}
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-center"
>
<span className="text-3xl">{provider.icon}</span>
<p className="font-medium mt-2">{provider.name}</p>
<p className="text-sm text-slate-400">{provider.model}</p>
</button>
))}
</div>
</div>
);
}
function ChannelContent() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Connect a Channel</h2>
<p className="text-slate-300">
Link a messaging app to start chatting with your AI
</p>
<div className="grid grid-cols-2 gap-4">
{[
{ type: 'whatsapp', name: 'WhatsApp', icon: '📱' },
{ type: 'telegram', name: 'Telegram', icon: '✈️' },
{ type: 'discord', name: 'Discord', icon: '🎮' },
{ type: 'slack', name: 'Slack', icon: '💼' },
].map((channel) => (
<button
key={channel.type}
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors flex items-center gap-3"
>
<span className="text-2xl">{channel.icon}</span>
<span className="font-medium">{channel.name}</span>
</button>
))}
</div>
<p className="text-sm text-slate-400 text-center">
You can add more channels later in Settings
</p>
</div>
);
}
function SkillsContent() {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Choose Skill Bundles</h2>
<p className="text-slate-300">
Select pre-configured skill packages
</p>
<div className="grid grid-cols-2 gap-4">
{[
{ id: 'productivity', name: 'Productivity', icon: '📋', recommended: true },
{ id: 'developer', name: 'Developer', icon: '💻', recommended: true },
{ id: 'smart-home', name: 'Smart Home', icon: '🏠' },
{ id: 'media', name: 'Media', icon: '🎨' },
].map((bundle) => (
<button
key={bundle.id}
className={cn(
'p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-left relative',
bundle.recommended && 'ring-2 ring-primary'
)}
>
<span className="text-2xl">{bundle.icon}</span>
<p className="font-medium mt-2">{bundle.name}</p>
{bundle.recommended && (
<span className="absolute top-2 right-2 text-xs bg-primary px-2 py-0.5 rounded">
Recommended
</span>
)}
</button>
))}
</div>
</div>
);
}
function CompleteContent() {
return (
<div className="text-center space-y-4">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-xl font-semibold">Setup Complete!</h2>
<p className="text-slate-300">
ClawX is configured and ready to use. You can now start chatting with
your AI assistant.
</p>
<div className="space-y-2 text-slate-300">
<p> AI Provider configured</p>
<p> Channel connected</p>
<p> Skills enabled</p>
<p> Gateway running</p>
</div>
</div>
);
}
export default Setup;

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

@@ -0,0 +1,198 @@
/**
* Skills Page
* Browse and manage AI skills
*/
import { useEffect, useState } from 'react';
import { Search, Puzzle, RefreshCw, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { useSkillsStore } from '@/stores/skills';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import type { SkillCategory } from '@/types/skill';
const categoryLabels: Record<SkillCategory, string> = {
productivity: 'Productivity',
developer: 'Developer',
'smart-home': 'Smart Home',
media: 'Media',
communication: 'Communication',
security: 'Security',
information: 'Information',
utility: 'Utility',
custom: 'Custom',
};
export function Skills() {
const { skills, loading, error, fetchSkills, enableSkill, disableSkill } = useSkillsStore();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<SkillCategory | 'all'>('all');
// Fetch skills on mount
useEffect(() => {
fetchSkills();
}, [fetchSkills]);
// Filter skills
const filteredSkills = skills.filter((skill) => {
const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || skill.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// Get unique categories
const categories = Array.from(new Set(skills.map((s) => s.category)));
// Handle toggle
const handleToggle = async (skillId: string, enabled: boolean) => {
try {
if (enabled) {
await disableSkill(skillId);
} else {
await enableSkill(skillId);
}
} catch (error) {
// Error handled in store
}
};
if (loading) {
return (
<div className="flex h-96 items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-muted-foreground">
Browse and manage AI skills
</p>
</div>
<Button variant="outline" onClick={fetchSkills}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Search and Filter */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2 flex-wrap">
<Button
variant={selectedCategory === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory('all')}
>
All
</Button>
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(category)}
>
{categoryLabels[category]}
</Button>
))}
</div>
</div>
{/* Error Display */}
{error && (
<Card className="border-destructive">
<CardContent className="py-4 text-destructive">
{error}
</CardContent>
</Card>
)}
{/* Skills Grid */}
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Puzzle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No skills found</h3>
<p className="text-muted-foreground">
{searchQuery ? 'Try a different search term' : 'No skills available'}
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSkills.map((skill) => (
<Card key={skill.id} className={cn(skill.enabled && 'border-primary/50')}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{skill.icon || '🔧'}</span>
<div>
<CardTitle className="text-base flex items-center gap-2">
{skill.name}
{skill.isCore && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
</CardTitle>
<CardDescription className="text-xs">
{categoryLabels[skill.category]}
</CardDescription>
</div>
</div>
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggle(skill.id, skill.enabled)}
disabled={skill.isCore}
/>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
{skill.version && (
<Badge variant="outline" className="mt-2 text-xs">
v{skill.version}
</Badge>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* Statistics */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
</span>
<span className="text-muted-foreground">
{skills.filter((s) => s.isCore).length} core skills
</span>
</div>
</CardContent>
</Card>
</div>
);
}
export default Skills;