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:
86
src/App.tsx
Normal file
86
src/App.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Root Application Component
|
||||
* Handles routing and global providers
|
||||
*/
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Channels } from './pages/Channels';
|
||||
import { Skills } from './pages/Skills';
|
||||
import { Cron } from './pages/Cron';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Setup } from './pages/Setup';
|
||||
import { useSettingsStore } from './stores/settings';
|
||||
import { useGatewayStore } from './stores/gateway';
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const initGateway = useGatewayStore((state) => state.init);
|
||||
|
||||
// Initialize Gateway connection on mount
|
||||
useEffect(() => {
|
||||
initGateway();
|
||||
}, [initGateway]);
|
||||
|
||||
// Listen for navigation events from main process
|
||||
useEffect(() => {
|
||||
const handleNavigate = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate);
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Apply theme
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
{/* Setup wizard (shown on first launch) */}
|
||||
<Route path="/setup/*" element={<Setup />} />
|
||||
|
||||
{/* Main application routes */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
{/* Global toast notifications */}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
74
src/components/common/ErrorBoundary.tsx
Normal file
74
src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches and displays errors in the component tree
|
||||
*/
|
||||
import { Component, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
An unexpected error occurred. Please try again.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{this.state.error && (
|
||||
<pre className="rounded-lg bg-muted p-4 text-sm overflow-auto max-h-40">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<Button onClick={this.handleReset} className="w-full">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
36
src/components/common/LoadingSpinner.tsx
Normal file
36
src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Loading Spinner Component
|
||||
* Displays a spinning loader animation
|
||||
*/
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
};
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<Loader2 className={cn('animate-spin text-primary', sizeClasses[size])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full page loading spinner
|
||||
*/
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/common/StatusBadge.tsx
Normal file
46
src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Status Badge Component
|
||||
* Displays connection/state status with color coding
|
||||
*/
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
label?: string;
|
||||
showDot?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<Status, { label: string; variant: 'success' | 'secondary' | 'warning' | 'destructive' }> = {
|
||||
connected: { label: 'Connected', variant: 'success' },
|
||||
running: { label: 'Running', variant: 'success' },
|
||||
disconnected: { label: 'Disconnected', variant: 'secondary' },
|
||||
stopped: { label: 'Stopped', variant: 'secondary' },
|
||||
connecting: { label: 'Connecting', variant: 'warning' },
|
||||
starting: { label: 'Starting', variant: 'warning' },
|
||||
error: { label: 'Error', variant: 'destructive' },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, label, showDot = true }: StatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
const displayLabel = label || config.label;
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1.5">
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
config.variant === 'success' && 'bg-green-600',
|
||||
config.variant === 'secondary' && 'bg-gray-400',
|
||||
config.variant === 'warning' && 'bg-yellow-600 animate-pulse',
|
||||
config.variant === 'destructive' && 'bg-red-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
75
src/components/layout/Header.tsx
Normal file
75
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Header Component
|
||||
* Top navigation bar with search and actions
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Search, Bell, Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
// Page titles mapping
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Dashboard',
|
||||
'/chat': 'Chat',
|
||||
'/channels': 'Channels',
|
||||
'/skills': 'Skills',
|
||||
'/cron': 'Cron Tasks',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
export function Header() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Get current page title
|
||||
const currentTitle = pageTitles[location.pathname] || 'ClawX';
|
||||
|
||||
// Cycle through themes
|
||||
const cycleTheme = () => {
|
||||
const themes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||
const currentIndex = themes.indexOf(theme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex]);
|
||||
};
|
||||
|
||||
// Get theme icon
|
||||
const ThemeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
|
||||
{/* Page Title */}
|
||||
<h2 className="text-lg font-semibold">{currentTitle}</h2>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
className="w-64 pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button variant="ghost" size="icon" onClick={cycleTheme}>
|
||||
<ThemeIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
36
src/components/layout/MainLayout.tsx
Normal file
36
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Main Layout Component
|
||||
* Provides the primary app layout with sidebar and content area
|
||||
*/
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MainLayout() {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col overflow-hidden transition-all duration-300',
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/layout/Sidebar.tsx
Normal file
206
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Sidebar Component
|
||||
* Navigation sidebar with menu items
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Puzzle,
|
||||
Clock,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
badge?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||
const setDevModeUnlocked = useSettingsStore((state) => state.setDevModeUnlocked);
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
|
||||
const [versionClicks, setVersionClicks] = useState(0);
|
||||
const [appVersion, setAppVersion] = useState('0.1.0');
|
||||
|
||||
// Get app version
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.invoke('app:version').then((version) => {
|
||||
setAppVersion(version as string);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle version click for dev mode unlock
|
||||
const handleVersionClick = () => {
|
||||
const clicks = versionClicks + 1;
|
||||
setVersionClicks(clicks);
|
||||
|
||||
if (clicks >= 5) {
|
||||
if (!devModeUnlocked) {
|
||||
setDevModeUnlocked(true);
|
||||
toast.success('Developer mode unlocked!');
|
||||
}
|
||||
setVersionClicks(0);
|
||||
}
|
||||
|
||||
// Reset after 2 seconds of inactivity
|
||||
setTimeout(() => setVersionClicks(0), 2000);
|
||||
};
|
||||
|
||||
// Open developer console
|
||||
const openDevConsole = () => {
|
||||
window.electron.openExternal('http://localhost:18789');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
||||
{ to: '/chat', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
||||
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: 'Channels' },
|
||||
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: 'Skills' },
|
||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
||||
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r bg-background transition-all duration-300',
|
||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Header with drag region for macOS */}
|
||||
<div className="drag-region flex h-14 items-center border-b px-4">
|
||||
{/* macOS traffic light spacing */}
|
||||
<div className="w-16" />
|
||||
{!sidebarCollapsed && (
|
||||
<h1 className="no-drag text-xl font-bold">ClawX</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{navItems.map((item) => (
|
||||
<NavItem
|
||||
key={item.to}
|
||||
{...item}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t p-2 space-y-2">
|
||||
{/* Gateway Status */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2',
|
||||
sidebarCollapsed && 'justify-center px-2'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
gatewayStatus.state === 'running' && 'bg-green-500',
|
||||
gatewayStatus.state === 'starting' && 'bg-yellow-500 animate-pulse',
|
||||
gatewayStatus.state === 'stopped' && 'bg-gray-400',
|
||||
gatewayStatus.state === 'error' && 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Gateway: {gatewayStatus.state}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Developer Mode Button */}
|
||||
{devModeUnlocked && !sidebarCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={openDevConsole}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Developer Console
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
<button
|
||||
onClick={handleVersionClick}
|
||||
className={cn(
|
||||
'w-full text-xs text-muted-foreground hover:text-foreground transition-colors',
|
||||
sidebarCollapsed ? 'text-center' : 'px-3'
|
||||
)}
|
||||
>
|
||||
{sidebarCollapsed ? `v${appVersion.split('.')[0]}` : `ClawX v${appVersion}`}
|
||||
</button>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-full"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/badge.tsx
Normal file
43
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Badge Component
|
||||
* Based on shadcn/ui badge
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success:
|
||||
'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
|
||||
warning:
|
||||
'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Button Component
|
||||
* Based on shadcn/ui button
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
82
src/components/ui/card.tsx
Normal file
82
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Card Component
|
||||
* Based on shadcn/ui card
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
28
src/components/ui/input.tsx
Normal file
28
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Input Component
|
||||
* Based on shadcn/ui input
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
27
src/components/ui/label.tsx
Normal file
27
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Label Component
|
||||
* Based on shadcn/ui label
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
32
src/components/ui/separator.tsx
Normal file
32
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Separator Component
|
||||
* Based on shadcn/ui separator
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
30
src/components/ui/switch.tsx
Normal file
30
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Switch Component
|
||||
* Based on shadcn/ui switch
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
72
src/lib/utils.ts
Normal file
72
src/lib/utils.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Utility Functions
|
||||
* Common utility functions for the application
|
||||
*/
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Merge class names with Tailwind CSS classes
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 minutes ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date();
|
||||
const then = new Date(date);
|
||||
const diffMs = now.getTime() - then.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 7) {
|
||||
return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return then.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human-readable string
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay for a specified number of milliseconds
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
16
src/main.tsx
Normal file
16
src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* React Application Entry Point
|
||||
*/
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
164
src/pages/Channels/index.tsx
Normal file
164
src/pages/Channels/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Channels Page
|
||||
* Manage messaging channel connections
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { Plus, Radio, RefreshCw, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useChannelsStore } from '@/stores/channels';
|
||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
|
||||
|
||||
export function Channels() {
|
||||
const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel } = useChannelsStore();
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Supported channel types for adding
|
||||
const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack'];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Channels</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect and manage your messaging channels
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="py-4 text-destructive">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Channels Grid */}
|
||||
{channels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Radio className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No channels configured</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Connect a messaging channel to start using ClawX
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Channel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
<Card key={channel.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">
|
||||
{CHANNEL_ICONS[channel.type]}
|
||||
</span>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{channel.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{CHANNEL_NAMES[channel.type]}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={channel.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.lastActivity && (
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Last activity: {new Date(channel.lastActivity).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{channel.error && (
|
||||
<p className="text-sm text-destructive mb-4">{channel.error}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{channel.status === 'connected' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => disconnectChannel(channel.id)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => connectChannel(channel.id)}
|
||||
disabled={channel.status === 'connecting'}
|
||||
>
|
||||
{channel.status === 'connecting' ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Channel Types */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Supported Channels</CardTitle>
|
||||
<CardDescription>
|
||||
Click on a channel type to add it
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{supportedTypes.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="h-auto flex-col gap-2 py-4"
|
||||
>
|
||||
<span className="text-3xl">{CHANNEL_ICONS[type]}</span>
|
||||
<span>{CHANNEL_NAMES[type]}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Channels;
|
||||
166
src/pages/Chat/index.tsx
Normal file
166
src/pages/Chat/index.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Chat Page
|
||||
* Conversation interface with AI
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send, Trash2, Bot, User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export function Chat() {
|
||||
const { messages, loading, sending, fetchHistory, sendMessage, clearHistory } = useChatStore();
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch history on mount
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Handle send message
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || sending) return;
|
||||
|
||||
const content = input.trim();
|
||||
setInput('');
|
||||
await sendMessage(content);
|
||||
};
|
||||
|
||||
// Handle key press
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Bot className="h-16 w-16 mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium">No messages yet</h3>
|
||||
<p className="text-sm">Start a conversation with your AI assistant</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
message.role === 'user' ? 'flex-row-reverse' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2',
|
||||
message.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xs',
|
||||
message.role === 'user'
|
||||
? 'text-primary-foreground/70'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(message.timestamp)}
|
||||
</p>
|
||||
|
||||
{/* Tool Calls */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{message.toolCalls.map((tool) => (
|
||||
<Card key={tool.id} className="bg-background/50">
|
||||
<CardContent className="p-2">
|
||||
<p className="text-xs font-medium">
|
||||
Tool: {tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Status: {tool.status}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearHistory}
|
||||
disabled={messages.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
disabled={sending}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button onClick={handleSend} disabled={!input.trim() || sending}>
|
||||
{sending ? (
|
||||
<LoadingSpinner size="sm" className="text-primary-foreground" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Chat;
|
||||
215
src/pages/Cron/index.tsx
Normal file
215
src/pages/Cron/index.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Cron Page
|
||||
* Manage scheduled tasks
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useCronStore } from '@/stores/cron';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import type { CronJob } from '@/types/cron';
|
||||
|
||||
export function Cron() {
|
||||
const { jobs, loading, error, fetchJobs, toggleJob, deleteJob, triggerJob } = useCronStore();
|
||||
|
||||
// Fetch jobs on mount
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
}, [fetchJobs]);
|
||||
|
||||
// Statistics
|
||||
const activeJobs = jobs.filter((j) => j.enabled);
|
||||
const pausedJobs = jobs.filter((j) => !j.enabled);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Cron Tasks</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Schedule automated AI tasks
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchJobs}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Clock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{jobs.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
|
||||
<Play className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{activeJobs.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Running</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-full bg-yellow-100 p-3 dark:bg-yellow-900">
|
||||
<Pause className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pausedJobs.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Paused</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="py-4 text-destructive">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Jobs List */}
|
||||
{jobs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No scheduled tasks</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Create your first scheduled task to automate AI workflows
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{jobs.map((job) => (
|
||||
<CronJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onToggle={(enabled) => toggleJob(job.id, enabled)}
|
||||
onDelete={() => deleteJob(job.id)}
|
||||
onTrigger={() => triggerJob(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronJobCardProps {
|
||||
job: CronJob;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
onTrigger: () => void;
|
||||
}
|
||||
|
||||
function CronJobCard({ job, onToggle, onDelete, onTrigger }: CronJobCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📋</span>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{job.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
{job.schedule}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={job.enabled ? 'success' : 'secondary'}>
|
||||
{job.enabled ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">
|
||||
{job.message}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Target: {job.target.channelName}
|
||||
</span>
|
||||
{job.lastRun && (
|
||||
<span>
|
||||
Last run: {formatRelativeTime(job.lastRun.time)}
|
||||
{job.lastRun.success ? ' ✓' : ' ✗'}
|
||||
</span>
|
||||
)}
|
||||
{job.nextRun && (
|
||||
<span>
|
||||
Next: {new Date(job.nextRun).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={onTrigger}>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cron;
|
||||
251
src/pages/Dashboard/index.tsx
Normal file
251
src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Dashboard Page
|
||||
* Main overview page showing system status and quick actions
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Puzzle,
|
||||
Clock,
|
||||
Settings,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useChannelsStore } from '@/stores/channels';
|
||||
import { useSkillsStore } from '@/stores/skills';
|
||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export function Dashboard() {
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const { channels, fetchChannels } = useChannelsStore();
|
||||
const { skills, fetchSkills } = useSkillsStore();
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
}, [fetchChannels, fetchSkills]);
|
||||
|
||||
// Calculate statistics
|
||||
const connectedChannels = channels.filter((c) => c.status === 'connected').length;
|
||||
const enabledSkills = skills.filter((s) => s.enabled).length;
|
||||
|
||||
// Calculate uptime
|
||||
const uptime = gatewayStatus.connectedAt
|
||||
? Math.floor((Date.now() - gatewayStatus.connectedAt) / 1000)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Gateway Status */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Gateway</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={gatewayStatus.state} />
|
||||
</div>
|
||||
{gatewayStatus.state === 'running' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Port: {gatewayStatus.port} | PID: {gatewayStatus.pid || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Channels */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Channels</CardTitle>
|
||||
<Radio className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{connectedChannels}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connectedChannels} of {channels.length} connected
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skills */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Skills</CardTitle>
|
||||
<Puzzle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{enabledSkills}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{enabledSkills} of {skills.length} enabled
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Uptime */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{uptime > 0 ? formatUptime(uptime) : '—'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{gatewayStatus.state === 'running' ? 'Since last restart' : 'Gateway not running'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common tasks and shortcuts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/channels">
|
||||
<Plus className="h-5 w-5" />
|
||||
<span>Add Channel</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/skills">
|
||||
<Puzzle className="h-5 w-5" />
|
||||
<span>Browse Skills</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/chat">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span>Open Chat</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/settings">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* Connected Channels */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Connected Channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channels.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No channels configured</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/channels">Add your first channel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{channels.slice(0, 5).map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">
|
||||
{channel.type === 'whatsapp' && '📱'}
|
||||
{channel.type === 'telegram' && '✈️'}
|
||||
{channel.type === 'discord' && '🎮'}
|
||||
{channel.type === 'slack' && '💼'}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">{channel.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{channel.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={channel.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Enabled Skills */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Active Skills</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{skills.filter((s) => s.enabled).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No skills enabled</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/skills">Enable some skills</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills
|
||||
.filter((s) => s.enabled)
|
||||
.slice(0, 12)
|
||||
.map((skill) => (
|
||||
<Badge key={skill.id} variant="secondary">
|
||||
{skill.icon && <span className="mr-1">{skill.icon}</span>}
|
||||
{skill.name}
|
||||
</Badge>
|
||||
))}
|
||||
{skills.filter((s) => s.enabled).length > 12 && (
|
||||
<Badge variant="outline">
|
||||
+{skills.filter((s) => s.enabled).length - 12} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime in human-readable format
|
||||
*/
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
282
src/pages/Settings/index.tsx
Normal file
282
src/pages/Settings/index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Settings Page
|
||||
* Application configuration
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
|
||||
export function Settings() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
gatewayAutoStart,
|
||||
setGatewayAutoStart,
|
||||
autoCheckUpdate,
|
||||
setAutoCheckUpdate,
|
||||
autoDownloadUpdate,
|
||||
setAutoDownloadUpdate,
|
||||
devModeUnlocked,
|
||||
} = useSettingsStore();
|
||||
|
||||
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
|
||||
|
||||
const [appVersion, setAppVersion] = useState('0.1.0');
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
|
||||
// Get app version
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.invoke('app:version').then((version) => {
|
||||
setAppVersion(version as string);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check for updates
|
||||
const handleCheckUpdate = async () => {
|
||||
setCheckingUpdate(true);
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('update:check');
|
||||
} finally {
|
||||
setCheckingUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open developer console
|
||||
const openDevConsole = () => {
|
||||
window.electron.openExternal('http://localhost:18789');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your ClawX experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>Customize the look and feel</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={theme === 'light' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
<Sun className="h-4 w-4 mr-2" />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'system' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
<Monitor className="h-4 w-4 mr-2" />
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gateway */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gateway</CardTitle>
|
||||
<CardDescription>OpenClaw Gateway settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Port: {gatewayStatus.port}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
gatewayStatus.state === 'running'
|
||||
? 'success'
|
||||
: gatewayStatus.state === 'error'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{gatewayStatus.state}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={restartGateway}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-start Gateway</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start Gateway when ClawX launches
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={gatewayAutoStart}
|
||||
onCheckedChange={setGatewayAutoStart}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Updates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Updates</CardTitle>
|
||||
<CardDescription>Keep ClawX up to date</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">ClawX</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Version {appVersion}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checkingUpdate}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Check for Updates'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-check for updates</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check for updates on startup
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoCheckUpdate}
|
||||
onCheckedChange={setAutoCheckUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-download updates</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download updates in the background
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoDownloadUpdate}
|
||||
onCheckedChange={setAutoDownloadUpdate}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Developer */}
|
||||
{devModeUnlocked && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Developer</CardTitle>
|
||||
<CardDescription>Advanced options for developers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>OpenClaw Console</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access the native OpenClaw management interface
|
||||
</p>
|
||||
<Button variant="outline" onClick={openDevConsole}>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Open Developer Console
|
||||
<ExternalLink className="h-3 w-3 ml-2" />
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Opens http://localhost:18789 in your browser
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>ClawX</strong> - Graphical AI Assistant
|
||||
</p>
|
||||
<p>Based on OpenClaw</p>
|
||||
<p>Version {appVersion}</p>
|
||||
<div className="flex gap-4 pt-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() => window.electron.openExternal('https://docs.clawx.app')}
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() => window.electron.openExternal('https://github.com/clawx/clawx')}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
323
src/pages/Setup/index.tsx
Normal file
323
src/pages/Setup/index.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Setup Wizard Page
|
||||
* First-time setup experience for new users
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const steps: SetupStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome to ClawX',
|
||||
description: 'Your AI assistant is ready to be configured',
|
||||
},
|
||||
{
|
||||
id: 'runtime',
|
||||
title: 'Environment Check',
|
||||
description: 'Verifying system requirements',
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'AI Provider',
|
||||
description: 'Configure your AI service',
|
||||
},
|
||||
{
|
||||
id: 'channel',
|
||||
title: 'Connect Channel',
|
||||
description: 'Link a messaging app',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
title: 'Choose Skills',
|
||||
description: 'Select your skill bundles',
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: 'All Set!',
|
||||
description: 'ClawX is ready to use',
|
||||
},
|
||||
];
|
||||
|
||||
export function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const step = steps[currentStep];
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
// Complete setup and go to dashboard
|
||||
navigate('/');
|
||||
} else {
|
||||
setCurrentStep((i) => i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex justify-center pt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full border-2 transition-colors',
|
||||
i < currentStep
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: i === currentStep
|
||||
? 'border-primary text-primary'
|
||||
: 'border-slate-600 text-slate-600'
|
||||
)}
|
||||
>
|
||||
{i < currentStep ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="text-sm">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8 transition-colors',
|
||||
i < currentStep ? 'bg-primary' : 'bg-slate-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="mx-auto max-w-2xl p-8"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">{step.title}</h1>
|
||||
<p className="text-slate-400">{step.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Step-specific content */}
|
||||
<div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8">
|
||||
{currentStep === 0 && <WelcomeContent />}
|
||||
{currentStep === 1 && <RuntimeContent />}
|
||||
{currentStep === 2 && <ProviderContent />}
|
||||
{currentStep === 3 && <ChannelContent />}
|
||||
{currentStep === 4 && <SkillsContent />}
|
||||
{currentStep === 5 && <CompleteContent />}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
{!isFirstStep && (
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isLastStep && (
|
||||
<Button variant="ghost" onClick={handleSkip}>
|
||||
Skip Setup
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleNext}>
|
||||
{isLastStep ? (
|
||||
'Get Started'
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step content components (simplified versions)
|
||||
function WelcomeContent() {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-6xl mb-4">🤖</div>
|
||||
<h2 className="text-xl font-semibold">Welcome to ClawX</h2>
|
||||
<p className="text-slate-300">
|
||||
ClawX is a graphical interface for OpenClaw, making it easy to use AI
|
||||
assistants across your favorite messaging platforms.
|
||||
</p>
|
||||
<ul className="text-left space-y-2 text-slate-300">
|
||||
<li>✅ Zero command-line required</li>
|
||||
<li>✅ Modern, beautiful interface</li>
|
||||
<li>✅ Pre-installed skill bundles</li>
|
||||
<li>✅ Cross-platform support</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Checking Environment</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||
<span>Node.js Runtime</span>
|
||||
<span className="text-green-400">✓ Installed</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||
<span>OpenClaw Package</span>
|
||||
<span className="text-green-400">✓ Ready</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
|
||||
<span>Gateway Service</span>
|
||||
<span className="text-green-400">✓ Running</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Select AI Provider</h2>
|
||||
<p className="text-slate-300">
|
||||
Choose your preferred AI model provider
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ id: 'anthropic', name: 'Anthropic', model: 'Claude', icon: '🤖' },
|
||||
{ id: 'openai', name: 'OpenAI', model: 'GPT-4', icon: '💚' },
|
||||
{ id: 'google', name: 'Google', model: 'Gemini', icon: '🔷' },
|
||||
].map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-center"
|
||||
>
|
||||
<span className="text-3xl">{provider.icon}</span>
|
||||
<p className="font-medium mt-2">{provider.name}</p>
|
||||
<p className="text-sm text-slate-400">{provider.model}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Connect a Channel</h2>
|
||||
<p className="text-slate-300">
|
||||
Link a messaging app to start chatting with your AI
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ type: 'whatsapp', name: 'WhatsApp', icon: '📱' },
|
||||
{ type: 'telegram', name: 'Telegram', icon: '✈️' },
|
||||
{ type: 'discord', name: 'Discord', icon: '🎮' },
|
||||
{ type: 'slack', name: 'Slack', icon: '💼' },
|
||||
].map((channel) => (
|
||||
<button
|
||||
key={channel.type}
|
||||
className="p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className="text-2xl">{channel.icon}</span>
|
||||
<span className="font-medium">{channel.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 text-center">
|
||||
You can add more channels later in Settings
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Choose Skill Bundles</h2>
|
||||
<p className="text-slate-300">
|
||||
Select pre-configured skill packages
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ id: 'productivity', name: 'Productivity', icon: '📋', recommended: true },
|
||||
{ id: 'developer', name: 'Developer', icon: '💻', recommended: true },
|
||||
{ id: 'smart-home', name: 'Smart Home', icon: '🏠' },
|
||||
{ id: 'media', name: 'Media', icon: '🎨' },
|
||||
].map((bundle) => (
|
||||
<button
|
||||
key={bundle.id}
|
||||
className={cn(
|
||||
'p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-left relative',
|
||||
bundle.recommended && 'ring-2 ring-primary'
|
||||
)}
|
||||
>
|
||||
<span className="text-2xl">{bundle.icon}</span>
|
||||
<p className="font-medium mt-2">{bundle.name}</p>
|
||||
{bundle.recommended && (
|
||||
<span className="absolute top-2 right-2 text-xs bg-primary px-2 py-0.5 rounded">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompleteContent() {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-6xl mb-4">🎉</div>
|
||||
<h2 className="text-xl font-semibold">Setup Complete!</h2>
|
||||
<p className="text-slate-300">
|
||||
ClawX is configured and ready to use. You can now start chatting with
|
||||
your AI assistant.
|
||||
</p>
|
||||
<div className="space-y-2 text-slate-300">
|
||||
<p>✅ AI Provider configured</p>
|
||||
<p>✅ Channel connected</p>
|
||||
<p>✅ Skills enabled</p>
|
||||
<p>✅ Gateway running</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Setup;
|
||||
198
src/pages/Skills/index.tsx
Normal file
198
src/pages/Skills/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Skills Page
|
||||
* Browse and manage AI skills
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Puzzle, RefreshCw, Lock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useSkillsStore } from '@/stores/skills';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillCategory } from '@/types/skill';
|
||||
|
||||
const categoryLabels: Record<SkillCategory, string> = {
|
||||
productivity: 'Productivity',
|
||||
developer: 'Developer',
|
||||
'smart-home': 'Smart Home',
|
||||
media: 'Media',
|
||||
communication: 'Communication',
|
||||
security: 'Security',
|
||||
information: 'Information',
|
||||
utility: 'Utility',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
export function Skills() {
|
||||
const { skills, loading, error, fetchSkills, enableSkill, disableSkill } = useSkillsStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<SkillCategory | 'all'>('all');
|
||||
|
||||
// Fetch skills on mount
|
||||
useEffect(() => {
|
||||
fetchSkills();
|
||||
}, [fetchSkills]);
|
||||
|
||||
// Filter skills
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
skill.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || skill.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// Get unique categories
|
||||
const categories = Array.from(new Set(skills.map((s) => s.category)));
|
||||
|
||||
// Handle toggle
|
||||
const handleToggle = async (skillId: string, enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await disableSkill(skillId);
|
||||
} else {
|
||||
await enableSkill(skillId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handled in store
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Skills</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse and manage AI skills
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchSkills}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search skills..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={selectedCategory === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{categoryLabels[category]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="py-4 text-destructive">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Skills Grid */}
|
||||
{filteredSkills.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Puzzle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No skills found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? 'Try a different search term' : 'No skills available'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSkills.map((skill) => (
|
||||
<Card key={skill.id} className={cn(skill.enabled && 'border-primary/50')}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{skill.icon || '🔧'}</span>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{skill.name}
|
||||
{skill.isCore && (
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{categoryLabels[skill.category]}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={() => handleToggle(skill.id, skill.enabled)}
|
||||
disabled={skill.isCore}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
{skill.version && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
v{skill.version}
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{skills.filter((s) => s.isCore).length} core skills
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Skills;
|
||||
90
src/stores/channels.ts
Normal file
90
src/stores/channels.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Channels State Store
|
||||
* Manages messaging channel state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Channel } from '../types/channel';
|
||||
|
||||
interface ChannelsState {
|
||||
channels: Channel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchChannels: () => Promise<void>;
|
||||
connectChannel: (channelId: string) => Promise<void>;
|
||||
disconnectChannel: (channelId: string) => Promise<void>;
|
||||
setChannels: (channels: Channel[]) => void;
|
||||
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
|
||||
}
|
||||
|
||||
export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
channels: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.list'
|
||||
) as { success: boolean; result?: Channel[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ channels: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch channels', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
connectChannel: async (channelId) => {
|
||||
const { updateChannel } = get();
|
||||
updateChannel(channelId, { status: 'connecting' });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.connect',
|
||||
{ channelId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateChannel(channelId, { status: 'connected' });
|
||||
} else {
|
||||
updateChannel(channelId, { status: 'error', error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
updateChannel(channelId, { status: 'error', error: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
disconnectChannel: async (channelId) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.disconnect',
|
||||
{ channelId }
|
||||
);
|
||||
|
||||
const { updateChannel } = get();
|
||||
updateChannel(channelId, { status: 'disconnected' });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect channel:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setChannels: (channels) => set({ channels }),
|
||||
|
||||
updateChannel: (channelId, updates) => {
|
||||
set((state) => ({
|
||||
channels: state.channels.map((channel) =>
|
||||
channel.id === channelId ? { ...channel, ...updates } : channel
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
129
src/stores/chat.ts
Normal file
129
src/stores/chat.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Chat State Store
|
||||
* Manages chat messages and conversation state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchHistory: (limit?: number) => Promise<void>;
|
||||
sendMessage: (content: string, channelId?: string) => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
messages: [],
|
||||
loading: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
|
||||
fetchHistory: async (limit = 50) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ limit, offset: 0 }
|
||||
) as { success: boolean; result?: ChatMessage[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ messages: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch history', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content, channelId) => {
|
||||
const { addMessage } = get();
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: channelId,
|
||||
};
|
||||
addMessage(userMessage);
|
||||
|
||||
set({ sending: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.send',
|
||||
{ content, channelId }
|
||||
) as { success: boolean; result?: ChatMessage; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
addMessage(result.result);
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to send message' });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error) });
|
||||
} finally {
|
||||
set({ sending: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearHistory: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('gateway:rpc', 'chat.clear');
|
||||
set({ messages: [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
}));
|
||||
},
|
||||
|
||||
updateMessage: (messageId, updates) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, ...updates } : msg
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
}));
|
||||
100
src/stores/cron.ts
Normal file
100
src/stores/cron.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Cron State Store
|
||||
* Manages scheduled task state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
|
||||
interface CronState {
|
||||
jobs: CronJob[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchJobs: () => Promise<void>;
|
||||
createJob: (input: CronJobCreateInput) => Promise<CronJob>;
|
||||
updateJob: (id: string, input: CronJobUpdateInput) => Promise<void>;
|
||||
deleteJob: (id: string) => Promise<void>;
|
||||
toggleJob: (id: string, enabled: boolean) => Promise<void>;
|
||||
triggerJob: (id: string) => Promise<void>;
|
||||
setJobs: (jobs: CronJob[]) => void;
|
||||
}
|
||||
|
||||
export const useCronStore = create<CronState>((set, get) => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchJobs: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||
set({ jobs: result, loading: false });
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createJob: async (input) => {
|
||||
try {
|
||||
const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob;
|
||||
set((state) => ({ jobs: [...state.jobs, job] }));
|
||||
return job;
|
||||
} catch (error) {
|
||||
console.error('Failed to create cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateJob: async (id, input) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:update', id, input);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to update cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteJob: async (id) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:delete', id);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.filter((job) => job.id !== id),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
toggleJob: async (id, enabled) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, enabled } : job
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
triggerJob: async (id) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setJobs: (jobs) => set({ jobs }),
|
||||
}));
|
||||
73
src/stores/gateway.ts
Normal file
73
src/stores/gateway.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Gateway State Store
|
||||
* Manages Gateway connection state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
|
||||
interface GatewayState {
|
||||
status: GatewayStatus;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
setStatus: (status: GatewayStatus) => void;
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
status: {
|
||||
state: 'stopped',
|
||||
port: 18789,
|
||||
},
|
||||
isInitialized: false,
|
||||
|
||||
init: async () => {
|
||||
if (get().isInitialized) return;
|
||||
|
||||
try {
|
||||
// Get initial status
|
||||
const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus;
|
||||
set({ status, isInitialized: true });
|
||||
|
||||
// Listen for status changes
|
||||
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
|
||||
set({ status: newStatus as GatewayStatus });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Gateway:', error);
|
||||
}
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' } });
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
set({ status: { ...get().status, state: 'error', error: result.error } });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ status: { ...get().status, state: 'error', error: String(error) } });
|
||||
}
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('gateway:stop');
|
||||
set({ status: { ...get().status, state: 'stopped' } });
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Gateway:', error);
|
||||
}
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
const { stop, start } = get();
|
||||
await stop();
|
||||
await start();
|
||||
},
|
||||
|
||||
setStatus: (status) => set({ status }),
|
||||
}));
|
||||
82
src/stores/settings.ts
Normal file
82
src/stores/settings.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Settings State Store
|
||||
* Manages application settings
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||
|
||||
interface SettingsState {
|
||||
// General
|
||||
theme: Theme;
|
||||
language: string;
|
||||
startMinimized: boolean;
|
||||
launchAtStartup: boolean;
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
gatewayPort: number;
|
||||
|
||||
// Update
|
||||
updateChannel: UpdateChannel;
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
devModeUnlocked: boolean;
|
||||
|
||||
// Actions
|
||||
setTheme: (theme: Theme) => void;
|
||||
setLanguage: (language: string) => void;
|
||||
setStartMinimized: (value: boolean) => void;
|
||||
setLaunchAtStartup: (value: boolean) => void;
|
||||
setGatewayAutoStart: (value: boolean) => void;
|
||||
setGatewayPort: (port: number) => void;
|
||||
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||
setAutoCheckUpdate: (value: boolean) => void;
|
||||
setAutoDownloadUpdate: (value: boolean) => void;
|
||||
setSidebarCollapsed: (value: boolean) => void;
|
||||
setDevModeUnlocked: (value: boolean) => void;
|
||||
resetSettings: () => void;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
theme: 'system' as Theme,
|
||||
language: 'en',
|
||||
startMinimized: false,
|
||||
launchAtStartup: false,
|
||||
gatewayAutoStart: true,
|
||||
gatewayPort: 18789,
|
||||
updateChannel: 'stable' as UpdateChannel,
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
sidebarCollapsed: false,
|
||||
devModeUnlocked: false,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...defaultSettings,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLanguage: (language) => set({ language }),
|
||||
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||
setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }),
|
||||
setGatewayPort: (gatewayPort) => set({ gatewayPort }),
|
||||
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||
setDevModeUnlocked: (devModeUnlocked) => set({ devModeUnlocked }),
|
||||
resetSettings: () => set(defaultSettings),
|
||||
}),
|
||||
{
|
||||
name: 'clawx-settings',
|
||||
}
|
||||
)
|
||||
);
|
||||
102
src/stores/skills.ts
Normal file
102
src/stores/skills.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Skills State Store
|
||||
* Manages skill/plugin state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Skill } from '../types/skill';
|
||||
|
||||
interface SkillsState {
|
||||
skills: Skill[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchSkills: () => Promise<void>;
|
||||
enableSkill: (skillId: string) => Promise<void>;
|
||||
disableSkill: (skillId: string) => Promise<void>;
|
||||
setSkills: (skills: Skill[]) => void;
|
||||
updateSkill: (skillId: string, updates: Partial<Skill>) => void;
|
||||
}
|
||||
|
||||
export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
skills: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSkills: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.list'
|
||||
) as { success: boolean; result?: Skill[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ skills: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch skills', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
enableSkill: async (skillId) => {
|
||||
const { updateSkill } = get();
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.enable',
|
||||
{ skillId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: true });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to enable skill');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enable skill:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
disableSkill: async (skillId) => {
|
||||
const { updateSkill, skills } = get();
|
||||
|
||||
// Check if skill is a core skill
|
||||
const skill = skills.find((s) => s.id === skillId);
|
||||
if (skill?.isCore) {
|
||||
throw new Error('Cannot disable core skill');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.disable',
|
||||
{ skillId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: false });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to disable skill');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disable skill:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setSkills: (skills) => set({ skills }),
|
||||
|
||||
updateSkill: (skillId, updates) => {
|
||||
set((state) => ({
|
||||
skills: state.skills.map((skill) =>
|
||||
skill.id === skillId ? { ...skill, ...updates } : skill
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
97
src/styles/globals.css
Normal file
97
src/styles/globals.css
Normal file
@@ -0,0 +1,97 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground/30;
|
||||
}
|
||||
|
||||
/* macOS traffic light spacing */
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.transition-theme {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
|
||||
}
|
||||
74
src/types/channel.ts
Normal file
74
src/types/channel.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Channel Type Definitions
|
||||
* Types for messaging channels (WhatsApp, Telegram, etc.)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported channel types
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel connection status
|
||||
*/
|
||||
export type ChannelStatus = 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
|
||||
/**
|
||||
* Channel data structure
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
status: ChannelStatus;
|
||||
lastActivity?: string;
|
||||
error?: string;
|
||||
avatar?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel configuration for each type
|
||||
*/
|
||||
export interface ChannelConfig {
|
||||
whatsapp: {
|
||||
phoneNumber?: string;
|
||||
};
|
||||
telegram: {
|
||||
botToken?: string;
|
||||
chatId?: string;
|
||||
};
|
||||
discord: {
|
||||
botToken?: string;
|
||||
guildId?: string;
|
||||
};
|
||||
slack: {
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
};
|
||||
wechat: {
|
||||
appId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel icons mapping
|
||||
*/
|
||||
export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||
whatsapp: '📱',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
wechat: '💬',
|
||||
};
|
||||
|
||||
/**
|
||||
* Channel display names
|
||||
*/
|
||||
export const CHANNEL_NAMES: Record<ChannelType, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
wechat: 'WeChat',
|
||||
};
|
||||
68
src/types/cron.ts
Normal file
68
src/types/cron.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Cron Job Type Definitions
|
||||
* Types for scheduled tasks
|
||||
*/
|
||||
|
||||
import { ChannelType } from './channel';
|
||||
|
||||
/**
|
||||
* Cron job target (where to send the result)
|
||||
*/
|
||||
export interface CronJobTarget {
|
||||
channelType: ChannelType;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job last run info
|
||||
*/
|
||||
export interface CronJobLastRun {
|
||||
time: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job data structure
|
||||
*/
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string;
|
||||
target: CronJobTarget;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRun?: CronJobLastRun;
|
||||
nextRun?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a cron job
|
||||
*/
|
||||
export interface CronJobCreateInput {
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string;
|
||||
target: CronJobTarget;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a cron job
|
||||
*/
|
||||
export interface CronJobUpdateInput {
|
||||
name?: string;
|
||||
message?: string;
|
||||
schedule?: string;
|
||||
target?: CronJobTarget;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule type for UI picker
|
||||
*/
|
||||
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';
|
||||
26
src/types/electron.d.ts
vendored
Normal file
26
src/types/electron.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Electron API Type Declarations
|
||||
* Types for the APIs exposed via contextBridge
|
||||
*/
|
||||
|
||||
export interface IpcRenderer {
|
||||
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
|
||||
on(channel: string, callback: (...args: unknown[]) => void): (() => void) | void;
|
||||
once(channel: string, callback: (...args: unknown[]) => void): void;
|
||||
off(channel: string, callback?: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
ipcRenderer: IpcRenderer;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
platform: NodeJS.Platform;
|
||||
isDev: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
25
src/types/gateway.ts
Normal file
25
src/types/gateway.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Gateway Type Definitions
|
||||
* Types for Gateway communication and data structures
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gateway connection status
|
||||
*/
|
||||
export interface GatewayStatus {
|
||||
state: 'stopped' | 'starting' | 'running' | 'error';
|
||||
port: number;
|
||||
pid?: number;
|
||||
uptime?: number;
|
||||
error?: string;
|
||||
connectedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway RPC response
|
||||
*/
|
||||
export interface GatewayRpcResponse<T = unknown> {
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
}
|
||||
64
src/types/skill.ts
Normal file
64
src/types/skill.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Skill Type Definitions
|
||||
* Types for skills/plugins
|
||||
*/
|
||||
|
||||
/**
|
||||
* Skill category
|
||||
*/
|
||||
export type SkillCategory =
|
||||
| 'productivity'
|
||||
| 'developer'
|
||||
| 'smart-home'
|
||||
| 'media'
|
||||
| 'communication'
|
||||
| 'security'
|
||||
| 'information'
|
||||
| 'utility'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Skill data structure
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category: SkillCategory;
|
||||
icon?: string;
|
||||
version?: string;
|
||||
author?: string;
|
||||
configurable?: boolean;
|
||||
isCore?: boolean;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill bundle (preset skill collection)
|
||||
*/
|
||||
export interface SkillBundle {
|
||||
id: string;
|
||||
name: string;
|
||||
nameZh: string;
|
||||
description: string;
|
||||
descriptionZh: string;
|
||||
icon: string;
|
||||
skills: string[];
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill configuration schema
|
||||
*/
|
||||
export interface SkillConfigSchema {
|
||||
type: 'object';
|
||||
properties: Record<string, {
|
||||
type: 'string' | 'number' | 'boolean' | 'array';
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
}>;
|
||||
required?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user