feat(app): i18n (#48)

This commit is contained in:
paisley
2026-02-11 15:34:53 +08:00
committed by GitHub
Unverified
parent 505a64438e
commit 6e09a69f4f
40 changed files with 3227 additions and 808 deletions

View File

@@ -6,6 +6,7 @@ import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Component, useEffect } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Toaster } from 'sonner';
import i18n from './i18n';
import { MainLayout } from './components/layout/MainLayout';
import { Dashboard } from './pages/Dashboard';
import { Chat } from './pages/Chat';
@@ -17,6 +18,7 @@ import { Setup } from './pages/Setup';
import { useSettingsStore } from './stores/settings';
import { useGatewayStore } from './stores/gateway';
/**
* Error Boundary to catch and display React rendering errors
*/
@@ -40,16 +42,16 @@ class ErrorBoundary extends Component<
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '40px',
color: '#f87171',
background: '#0f172a',
<div style={{
padding: '40px',
color: '#f87171',
background: '#0f172a',
minHeight: '100vh',
fontFamily: 'monospace'
}}>
<h1 style={{ fontSize: '24px', marginBottom: '16px' }}>Something went wrong</h1>
<pre style={{
whiteSpace: 'pre-wrap',
<pre style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
background: '#1e293b',
padding: '16px',
@@ -85,21 +87,29 @@ function App() {
const navigate = useNavigate();
const location = useLocation();
const theme = useSettingsStore((state) => state.theme);
const language = useSettingsStore((state) => state.language);
const setupComplete = useSettingsStore((state) => state.setupComplete);
const initGateway = useGatewayStore((state) => state.init);
// Sync i18n language with persisted settings on mount
useEffect(() => {
if (language && language !== i18n.language) {
i18n.changeLanguage(language);
}
}, [language]);
// Initialize Gateway connection on mount
useEffect(() => {
initGateway();
}, [initGateway]);
// Redirect to setup wizard if not complete
useEffect(() => {
if (!setupComplete && !location.pathname.startsWith('/setup')) {
navigate('/setup');
}
}, [setupComplete, location.pathname, navigate]);
// Listen for navigation events from main process
useEffect(() => {
const handleNavigate = (...args: unknown[]) => {
@@ -108,21 +118,21 @@ function App() {
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'
@@ -132,13 +142,13 @@ function App() {
root.classList.add(theme);
}
}, [theme]);
return (
<ErrorBoundary>
<Routes>
{/* Setup wizard (shown on first launch) */}
<Route path="/setup/*" element={<Setup />} />
{/* Main application routes */}
<Route element={<MainLayout />}>
<Route path="/" element={<Chat />} />
@@ -149,7 +159,7 @@ function App() {
<Route path="/settings/*" element={<Settings />} />
</Route>
</Routes>
{/* Global toast notifications */}
<Toaster
position="bottom-right"

View File

@@ -20,6 +20,7 @@ import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
interface NavItemProps {
to: string;
@@ -81,13 +82,15 @@ export function Sidebar() {
}
};
const { t } = useTranslation();
const navItems = [
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: 'Skills' },
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: 'Channels' },
{ to: '/dashboard', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: t('sidebar.chat') },
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: t('sidebar.skills') },
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: t('sidebar.channels') },
{ to: '/dashboard', icon: <Home className="h-5 w-5" />, label: t('sidebar.dashboard') },
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: t('sidebar.settings') },
];
return (
@@ -118,7 +121,7 @@ export function Sidebar() {
onClick={openDevConsole}
>
<Terminal className="h-4 w-4 mr-2" />
Developer Console
{t('sidebar.devConsole')}
<ExternalLink className="h-3 w-3 ml-auto" />
</Button>
)}

View File

@@ -3,14 +3,14 @@
* Manage AI provider configurations and API keys
*/
import { useState, useEffect } from 'react';
import {
Plus,
Trash2,
Edit,
Eye,
EyeOff,
Check,
X,
import {
Plus,
Trash2,
Edit,
Eye,
EyeOff,
Check,
X,
Loader2,
Star,
Key,
@@ -29,13 +29,15 @@ import {
} from '@/lib/providers';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
export function ProvidersSettings() {
const {
providers,
defaultProviderId,
loading,
fetchProviders,
const { t } = useTranslation('settings');
const {
providers,
defaultProviderId,
loading,
fetchProviders,
addProvider,
updateProvider,
deleteProvider,
@@ -43,15 +45,15 @@ export function ProvidersSettings() {
setDefaultProvider,
validateApiKey,
} = useProviderStore();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
// Fetch providers on mount
useEffect(() => {
fetchProviders();
}, [fetchProviders]);
const handleAddProvider = async (
type: ProviderType,
name: string,
@@ -80,47 +82,47 @@ export function ProvidersSettings() {
}
setShowAddDialog(false);
toast.success('Provider added successfully');
toast.success(t('aiProviders.toast.added'));
} catch (error) {
toast.error(`Failed to add provider: ${error}`);
toast.error(`${t('aiProviders.toast.failedAdd')}: ${error}`);
}
};
const handleDeleteProvider = async (providerId: string) => {
try {
await deleteProvider(providerId);
toast.success('Provider deleted');
toast.success(t('aiProviders.toast.deleted'));
} catch (error) {
toast.error(`Failed to delete provider: ${error}`);
toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`);
}
};
const handleSetDefault = async (providerId: string) => {
try {
await setDefaultProvider(providerId);
toast.success('Default provider updated');
toast.success(t('aiProviders.toast.defaultUpdated'));
} catch (error) {
toast.error(`Failed to set default: ${error}`);
toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`);
}
};
const handleToggleEnabled = async (provider: ProviderWithKeyInfo) => {
try {
await updateProvider(provider.id, { enabled: !provider.enabled });
} catch (error) {
toast.error(`Failed to update provider: ${error}`);
toast.error(`${t('aiProviders.toast.failedUpdate')}: ${error}`);
}
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button size="sm" onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Provider
{t('aiProviders.add')}
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -129,13 +131,13 @@ export function ProvidersSettings() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Key className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No providers configured</h3>
<h3 className="text-lg font-medium mb-2">{t('aiProviders.empty.title')}</h3>
<p className="text-muted-foreground text-center mb-4">
Add an AI provider to start using ClawX
{t('aiProviders.empty.desc')}
</p>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Provider
{t('aiProviders.empty.cta')}
</Button>
</CardContent>
</Card>
@@ -165,7 +167,7 @@ export function ProvidersSettings() {
))}
</div>
)}
{/* Add Provider Dialog */}
{showAddDialog && (
<AddProviderDialog
@@ -195,20 +197,7 @@ interface ProviderCardProps {
) => Promise<{ valid: boolean; error?: string }>;
}
/**
* Shorten a masked key to a more readable format.
* e.g. "sk-or-v1-a20a****df67" -> "sk-...df67"
*/
function shortenKeyDisplay(masked: string | null): string {
if (!masked) return 'No key';
// Show first 4 chars + last 4 chars
if (masked.length > 12) {
const prefix = masked.substring(0, 4);
const suffix = masked.substring(masked.length - 4);
return `${prefix}...${suffix}`;
}
return masked;
}
function ProviderCard({
provider,
@@ -222,13 +211,14 @@ function ProviderCard({
onSaveEdits,
onValidateKey,
}: ProviderCardProps) {
const { t } = useTranslation('settings');
const [newKey, setNewKey] = useState('');
const [baseUrl, setBaseUrl] = useState(provider.baseUrl || '');
const [modelId, setModelId] = useState(provider.model || '');
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [saving, setSaving] = useState(false);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type);
const canEditConfig = Boolean(typeInfo?.showBaseUrl || typeInfo?.showModelId);
@@ -240,7 +230,7 @@ function ProviderCard({
setModelId(provider.model || '');
}
}, [isEditing, provider.baseUrl, provider.model]);
const handleSaveEdits = async () => {
setSaving(true);
try {
@@ -253,7 +243,7 @@ function ProviderCard({
});
setValidating(false);
if (!result.valid) {
toast.error(result.error || 'Invalid API key');
toast.error(result.error || t('aiProviders.toast.invalidKey'));
setSaving(false);
return;
}
@@ -262,7 +252,7 @@ function ProviderCard({
if (canEditConfig) {
if (typeInfo?.showModelId && !modelId.trim()) {
toast.error('Model ID is required');
toast.error(t('aiProviders.toast.modelRequired'));
setSaving(false);
return;
}
@@ -287,15 +277,15 @@ function ProviderCard({
await onSaveEdits(payload);
setNewKey('');
toast.success('Provider updated');
toast.success(t('aiProviders.toast.updated'));
} catch (error) {
toast.error(`Failed to save provider: ${error}`);
toast.error(`${t('aiProviders.toast.failedUpdate')}: ${error}`);
} finally {
setSaving(false);
setValidating(false);
}
};
return (
<Card className={cn(isDefault && 'ring-2 ring-primary')}>
<CardContent className="p-4">
@@ -307,7 +297,7 @@ function ProviderCard({
<div className="flex items-center gap-2">
<span className="font-semibold">{provider.name}</span>
{isDefault && (
<Badge variant="default" className="text-xs">Default</Badge>
<Badge variant="default" className="text-xs">{t('aiProviders.card.default')}</Badge>
)}
</div>
<span className="text-xs text-muted-foreground capitalize">{provider.type}</span>
@@ -318,7 +308,7 @@ function ProviderCard({
onCheckedChange={onToggleEnabled}
/>
</div>
{/* Key row */}
{isEditing ? (
<div className="space-y-2">
@@ -326,7 +316,7 @@ function ProviderCard({
<>
{typeInfo?.showBaseUrl && (
<div className="space-y-1">
<Label className="text-xs">Base URL</Label>
<Label className="text-xs">{t('aiProviders.dialog.baseUrl')}</Label>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
@@ -337,7 +327,7 @@ function ProviderCard({
)}
{typeInfo?.showModelId && (
<div className="space-y-1">
<Label className="text-xs">Model ID</Label>
<Label className="text-xs">{t('aiProviders.dialog.modelId')}</Label>
<Input
value={modelId}
onChange={(e) => setModelId(e.target.value)}
@@ -352,7 +342,7 @@ function ProviderCard({
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : 'Optional: update API key'}
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : t('aiProviders.card.editKey')}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="pr-10 h-9 text-sm"
@@ -365,8 +355,8 @@ function ProviderCard({
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={handleSaveEdits}
disabled={
@@ -396,22 +386,26 @@ function ProviderCard({
<div className="flex items-center gap-2 min-w-0">
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-mono text-muted-foreground truncate">
{provider.hasKey ? shortenKeyDisplay(provider.keyMasked) : 'No API key set'}
{provider.hasKey
? (provider.keyMasked && provider.keyMasked.length > 12
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
: provider.keyMasked)
: t('aiProviders.card.noKey')}
</span>
{provider.hasKey && (
<Badge variant="secondary" className="text-xs shrink-0">Configured</Badge>
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
)}
</div>
<div className="flex gap-0.5 shrink-0 ml-2">
{!isDefault && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onSetDefault} title="Set as default">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onSetDefault} title={t('aiProviders.card.setDefault')}>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit} title="Edit API key">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit} title={t('aiProviders.card.editKey')}>
<Edit className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDelete} title="Delete provider">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDelete} title={t('aiProviders.card.delete')}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
@@ -439,6 +433,7 @@ interface AddProviderDialogProps {
}
function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: AddProviderDialogProps) {
const { t } = useTranslation('settings');
const [selectedType, setSelectedType] = useState<ProviderType | null>(null);
const [name, setName] = useState('');
const [apiKey, setApiKey] = useState('');
@@ -447,14 +442,14 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === selectedType);
// Only custom can be added multiple times.
const availableTypes = PROVIDER_TYPE_INFO.filter(
(t) => t.id === 'custom' || !existingTypes.has(t.id),
);
const handleAdd = async () => {
if (!selectedType) return;
@@ -465,7 +460,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
// Validate key first if the provider requires one and a key was entered
const requiresKey = typeInfo?.requiresApiKey ?? false;
if (requiresKey && !apiKey.trim()) {
setValidationError('API key is required');
setValidationError(t('aiProviders.toast.invalidKey')); // reusing invalid key msg or should add 'required' msg? null checks
setSaving(false);
return;
}
@@ -474,7 +469,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
baseUrl: baseUrl.trim() || undefined,
});
if (!result.valid) {
setValidationError(result.error || 'Invalid API key');
setValidationError(result.error || t('aiProviders.toast.invalidKey'));
setSaving(false);
return;
}
@@ -482,7 +477,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
const requiresModel = typeInfo?.showModelId ?? false;
if (requiresModel && !modelId.trim()) {
setValidationError('Model ID is required');
setValidationError(t('aiProviders.toast.modelRequired'));
setSaving(false);
return;
}
@@ -502,14 +497,14 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Add AI Provider</CardTitle>
<CardTitle>{t('aiProviders.dialog.title')}</CardTitle>
<CardDescription>
Configure a new AI model provider
{t('aiProviders.dialog.desc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -537,7 +532,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
<span className="text-2xl">{typeInfo?.icon}</span>
<div>
<p className="font-medium">{typeInfo?.name}</p>
<button
<button
onClick={() => {
setSelectedType(null);
setValidationError(null);
@@ -546,13 +541,13 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
}}
className="text-sm text-muted-foreground hover:text-foreground"
>
Change provider
{t('aiProviders.dialog.change')}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Label htmlFor="name">{t('aiProviders.dialog.displayName')}</Label>
<Input
id="name"
placeholder={typeInfo?.name}
@@ -560,9 +555,9 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Label htmlFor="apiKey">{t('aiProviders.dialog.apiKey')}</Label>
<div className="relative">
<Input
id="apiKey"
@@ -587,13 +582,13 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
<p className="text-xs text-destructive">{validationError}</p>
)}
<p className="text-xs text-muted-foreground">
Your API key is stored locally on your machine.
{t('aiProviders.dialog.apiKeyStored')}
</p>
</div>
{typeInfo?.showBaseUrl && (
<div className="space-y-2">
<Label htmlFor="baseUrl">Base URL</Label>
<Label htmlFor="baseUrl">{t('aiProviders.dialog.baseUrl')}</Label>
<Input
id="baseUrl"
placeholder="https://api.example.com/v1"
@@ -605,7 +600,7 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
{typeInfo?.showModelId && (
<div className="space-y-2">
<Label htmlFor="modelId">Model ID</Label>
<Label htmlFor="modelId">{t('aiProviders.dialog.modelId')}</Label>
<Input
id="modelId"
placeholder={typeInfo.modelIdPlaceholder || 'provider/model-id'}
@@ -619,21 +614,21 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add
)}
</div>
)}
<Separator />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
{t('aiProviders.dialog.cancel')}
</Button>
<Button
onClick={handleAdd}
<Button
onClick={handleAdd}
disabled={!selectedType || saving || ((typeInfo?.showModelId ?? false) && modelId.trim().length === 0)}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Add Provider
{t('aiProviders.dialog.add')}
</Button>
</div>
</CardContent>

View File

@@ -7,6 +7,7 @@ import { Download, RefreshCw, Loader2, Rocket } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useUpdateStore } from '@/stores/update';
import { useTranslation } from 'react-i18next';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -17,6 +18,7 @@ function formatBytes(bytes: number): string {
}
export function UpdateSettings() {
const { t } = useTranslation('settings');
const {
status,
currentVersion,
@@ -41,22 +43,38 @@ export function UpdateSettings() {
await checkForUpdates();
}, [checkForUpdates, clearError]);
const renderStatusIcon = () => {
switch (status) {
case 'checking':
case 'downloading':
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
case 'available':
return <Download className="h-4 w-4 text-primary" />;
case 'downloaded':
return <Rocket className="h-4 w-4 text-primary" />;
case 'error':
return <RefreshCw className="h-4 w-4 text-destructive" />;
default:
return <RefreshCw className="h-4 w-4 text-muted-foreground" />;
}
};
const renderStatusText = () => {
switch (status) {
case 'checking':
return 'Checking for updates...';
return t('updates.status.checking');
case 'downloading':
return 'Downloading update...';
return t('updates.status.downloading');
case 'available':
return `Update available: v${updateInfo?.version}`;
return t('updates.status.available', { version: updateInfo?.version });
case 'downloaded':
return `Ready to install: v${updateInfo?.version}`;
return t('updates.status.downloaded', { version: updateInfo?.version });
case 'error':
return 'Update check failed';
return error || t('updates.status.failed');
case 'not-available':
return 'You have the latest version';
return t('updates.status.latest');
default:
return 'Check for updates to get the latest features';
return t('updates.status.check');
}
};
@@ -66,42 +84,42 @@ export function UpdateSettings() {
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
{t('updates.action.checking')}
</Button>
);
case 'downloading':
return (
<Button disabled variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading...
{t('updates.action.downloading')}
</Button>
);
case 'available':
return (
<Button onClick={downloadUpdate} size="sm">
<Download className="h-4 w-4 mr-2" />
Download Update
{t('updates.action.download')}
</Button>
);
case 'downloaded':
return (
<Button onClick={installUpdate} size="sm" variant="default">
<Rocket className="h-4 w-4 mr-2" />
Install & Restart
{t('updates.action.install')}
</Button>
);
case 'error':
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
{t('updates.action.retry')}
</Button>
);
default:
return (
<Button onClick={handleCheckForUpdates} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Check for Updates
{t('updates.action.check')}
</Button>
);
}
@@ -119,9 +137,12 @@ export function UpdateSettings() {
return (
<div className="space-y-4">
{/* Current Version */}
<div className="space-y-1">
<p className="text-sm font-medium">Current Version</p>
<p className="text-2xl font-bold">v{currentVersion}</p>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('updates.currentVersion')}</p>
<p className="text-2xl font-bold">v{currentVersion}</p>
</div>
{renderStatusIcon()}
</div>
{/* Status */}
@@ -159,7 +180,7 @@ export function UpdateSettings() {
</div>
{updateInfo.releaseNotes && (
<div className="text-sm text-muted-foreground prose prose-sm max-w-none">
<p className="font-medium text-foreground mb-1">What's New:</p>
<p className="font-medium text-foreground mb-1">{t('updates.whatsNew')}</p>
<p className="whitespace-pre-wrap">{updateInfo.releaseNotes}</p>
</div>
)}
@@ -169,14 +190,14 @@ export function UpdateSettings() {
{/* Error Details */}
{status === 'error' && error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/10 p-4 text-red-600 dark:text-red-400 text-sm">
<p className="font-medium mb-1">Error Details:</p>
<p className="font-medium mb-1">{t('updates.errorDetails')}</p>
<p>{error}</p>
</div>
)}
{/* Help Text */}
<p className="text-xs text-muted-foreground">
Updates are downloaded in the background and installed when you restart the app.
{t('updates.help')}
</p>
</div>
);

88
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,88 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
// EN
import enCommon from './locales/en/common.json';
import enSettings from './locales/en/settings.json';
import enDashboard from './locales/en/dashboard.json';
import enChat from './locales/en/chat.json';
import enChannels from './locales/en/channels.json';
import enSkills from './locales/en/skills.json';
import enCron from './locales/en/cron.json';
import enSetup from './locales/en/setup.json';
// ZH
import zhCommon from './locales/zh/common.json';
import zhSettings from './locales/zh/settings.json';
import zhDashboard from './locales/zh/dashboard.json';
import zhChat from './locales/zh/chat.json';
import zhChannels from './locales/zh/channels.json';
import zhSkills from './locales/zh/skills.json';
import zhCron from './locales/zh/cron.json';
import zhSetup from './locales/zh/setup.json';
// JA
import jaCommon from './locales/ja/common.json';
import jaSettings from './locales/ja/settings.json';
import jaDashboard from './locales/ja/dashboard.json';
import jaChat from './locales/ja/chat.json';
import jaChannels from './locales/ja/channels.json';
import jaSkills from './locales/ja/skills.json';
import jaCron from './locales/ja/cron.json';
import jaSetup from './locales/ja/setup.json';
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'ja', label: '日本語' },
] as const;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
const resources = {
en: {
common: enCommon,
settings: enSettings,
dashboard: enDashboard,
chat: enChat,
channels: enChannels,
skills: enSkills,
cron: enCron,
setup: enSetup,
},
zh: {
common: zhCommon,
settings: zhSettings,
dashboard: zhDashboard,
chat: zhChat,
channels: zhChannels,
skills: zhSkills,
cron: zhCron,
setup: zhSetup,
},
ja: {
common: jaCommon,
settings: jaSettings,
dashboard: jaDashboard,
chat: jaChat,
channels: jaChannels,
skills: jaSkills,
cron: jaCron,
setup: jaSetup,
},
};
i18n
.use(initReactI18next)
.init({
resources,
lng: 'en', // will be overridden by settings store
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common', 'settings', 'dashboard', 'chat', 'channels', 'skills', 'cron', 'setup'],
interpolation: {
escapeValue: false, // React already escapes
},
});
export default i18n;

View File

@@ -0,0 +1,263 @@
{
"title": "Messaging Channels",
"subtitle": "Manage your messaging channels and connections",
"refresh": "Refresh",
"addChannel": "Add Channel",
"stats": {
"total": "Total Channels",
"connected": "Connected",
"disconnected": "Disconnected"
},
"gatewayWarning": "Gateway service is not running. Channels cannot connect.",
"available": "Available Channels",
"availableDesc": "Connect a new channel",
"showAll": "Show All",
"pluginBadge": "Plugin",
"toast": {
"whatsappConnected": "WhatsApp connected successfully",
"whatsappFailed": "WhatsApp connection failed: {{error}}",
"channelSaved": "Channel {{name}} saved",
"channelConnecting": "Connecting to {{name}}...",
"restartManual": "Please restart the gateway manually",
"configFailed": "Configuration failed: {{error}}"
},
"dialog": {
"updateTitle": "Update {{name}}",
"configureTitle": "Configure {{name}}",
"addTitle": "Add Channel",
"existingDesc": "Update your existing configuration",
"selectDesc": "Select a channel type to configure",
"qrCode": "QRCode",
"token": "Token",
"scanQR": "Scan this QR code with {{name}}",
"refreshCode": "Refresh Code",
"loadingConfig": "Loading configuration...",
"existingHint": "You have an existing configuration for this channel",
"howToConnect": "How to connect",
"viewDocs": "View Documentation",
"channelName": "Channel Name",
"channelNamePlaceholder": "My {{name}}",
"credentialsVerified": "Credentials Verified",
"validationFailed": "Validation Failed",
"warnings": "Warnings",
"back": "Back",
"validating": "Validating...",
"validateConfig": "Validate Configuration",
"generatingQR": "Generating QR...",
"validatingAndSaving": "Validating & Saving...",
"generateQRCode": "Generate QR Code",
"updateAndReconnect": "Update & Reconnect",
"saveAndConnect": "Save & Connect",
"envVar": "Environment Variable: {{var}}"
},
"meta": {
"telegram": {
"description": "Connect Telegram using a bot token from @BotFather",
"docsUrl": "https://docs.openclaw.ai/channels/telegram",
"fields": {
"botToken": {
"label": "Bot Token",
"placeholder": "123456:ABC-DEF..."
},
"allowedUsers": {
"label": "Allowed User IDs",
"placeholder": "e.g. 123456789, 987654321",
"description": "Comma separated list of User IDs allowed to use the bot. Required for security."
}
},
"instructions": [
"Open Telegram and search for @BotFather",
"Send /newbot and follow the instructions",
"Copy the bot token provided",
"Paste the token below",
"Get your User ID from @userinfobot and paste it below"
]
},
"discord": {
"description": "Connect Discord using a bot token from Developer Portal",
"docsUrl": "https://docs.openclaw.ai/channels/discord#how-to-create-your-own-bot",
"fields": {
"token": {
"label": "Bot Token",
"placeholder": "Your Discord bot token"
},
"guildId": {
"label": "Guild/Server ID",
"placeholder": "e.g., 123456789012345678",
"description": "Limit bot to a specific server. Right-click server → Copy Server ID."
},
"channelId": {
"label": "Channel ID (optional)",
"placeholder": "e.g., 123456789012345678",
"description": "Limit bot to a specific channel. Right-click channel → Copy Channel ID."
}
},
"instructions": [
"Go to Discord Developer Portal → Applications → New Application",
"In Bot section: Add Bot, then copy the Bot Token",
"Enable Message Content Intent + Server Members Intent in Bot → Privileged Gateway Intents",
"In OAuth2 → URL Generator: select \"bot\" + \"applications.commands\", add message permissions",
"Invite the bot to your server using the generated URL",
"Paste the bot token below"
]
},
"whatsapp": {
"description": "Connect WhatsApp by scanning a QR code (no phone number required)",
"docsUrl": "https://docs.openclaw.ai/channels/whatsapp",
"instructions": [
"Open WhatsApp on your phone",
"Go to Settings > Linked Devices > Link a Device",
"Scan the QR code shown below",
"The system will automatically identify your phone number"
]
},
"signal": {
"description": "Connect Signal using signal-cli",
"docsUrl": "https://docs.openclaw.ai/channels/signal",
"fields": {
"phoneNumber": {
"label": "Phone Number",
"placeholder": "+1234567890"
}
},
"instructions": [
"Install signal-cli on your system",
"Register or link your phone number",
"Enter your phone number below"
]
},
"feishu": {
"description": "Connect Feishu/Lark bot via WebSocket",
"docsUrl": "https://docs.openclaw.ai/channels/feishu#step-1-create-a-feishu-app",
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxxxxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "Your app secret"
}
},
"instructions": [
"Go to Feishu Open Platform",
"Create a new application",
"Get App ID and App Secret",
"Configure event subscription"
]
},
"imessage": {
"description": "Connect iMessage via BlueBubbles (macOS)",
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles",
"fields": {
"serverUrl": {
"label": "BlueBubbles Server URL",
"placeholder": "http://localhost:1234"
},
"password": {
"label": "Server Password",
"placeholder": "Your server password"
}
},
"instructions": [
"Install BlueBubbles server on your Mac",
"Note the server URL and password",
"Enter the connection details below"
]
},
"matrix": {
"description": "Connect to Matrix protocol",
"docsUrl": "https://docs.openclaw.ai/channels/matrix",
"fields": {
"homeserver": {
"label": "Homeserver URL",
"placeholder": "https://matrix.org"
},
"accessToken": {
"label": "Access Token",
"placeholder": "Your access token"
}
},
"instructions": [
"Create a Matrix account or use existing",
"Get an access token from your client",
"Enter the homeserver and token below"
]
},
"line": {
"description": "Connect LINE Messaging API",
"docsUrl": "https://docs.openclaw.ai/channels/line",
"fields": {
"channelAccessToken": {
"label": "Channel Access Token",
"placeholder": "Your LINE channel access token"
},
"channelSecret": {
"label": "Channel Secret",
"placeholder": "Your LINE channel secret"
}
},
"instructions": [
"Go to LINE Developers Console",
"Create a Messaging API channel",
"Get Channel Access Token and Secret"
]
},
"msteams": {
"description": "Connect Microsoft Teams via Bot Framework",
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
"fields": {
"appId": {
"label": "App ID",
"placeholder": "Your Microsoft App ID"
},
"appPassword": {
"label": "App Password",
"placeholder": "Your Microsoft App Password"
}
},
"instructions": [
"Go to Azure Portal",
"Register a new Bot application",
"Get App ID and create a password",
"Configure Teams channel"
]
},
"googlechat": {
"description": "Connect Google Chat via webhook",
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
"fields": {
"serviceAccountKey": {
"label": "Service Account JSON Path",
"placeholder": "/path/to/service-account.json"
}
},
"instructions": [
"Create a Google Cloud project",
"Enable Google Chat API",
"Create a service account",
"Download the JSON key file"
]
},
"mattermost": {
"description": "Connect Mattermost via Bot API",
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
"fields": {
"serverUrl": {
"label": "Server URL",
"placeholder": "https://your-mattermost.com"
},
"botToken": {
"label": "Bot Access Token",
"placeholder": "Your bot access token"
}
},
"instructions": [
"Go to Mattermost Integrations",
"Create a new Bot Account",
"Copy the access token"
]
}
},
"viewDocs": "View Documentation"
}

View File

@@ -0,0 +1,13 @@
{
"gatewayNotRunning": "Gateway Not Running",
"gatewayRequired": "The OpenClaw Gateway needs to be running to use chat. It will start automatically, or you can start it from Settings.",
"welcome": {
"title": "ClawX Chat",
"subtitle": "Your AI assistant is ready. Start a conversation below.",
"askQuestions": "Ask Questions",
"askQuestionsDesc": "Get answers on any topic",
"creativeTasks": "Creative Tasks",
"creativeTasksDesc": "Writing, brainstorming, ideas"
},
"noLogs": "(No logs available yet)"
}

View File

@@ -0,0 +1,51 @@
{
"sidebar": {
"chat": "Chat",
"cronTasks": "Cron Tasks",
"skills": "Skills",
"channels": "Channels",
"dashboard": "Dashboard",
"settings": "Settings",
"devConsole": "Developer Console"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"refresh": "Refresh",
"close": "Close",
"copy": "Copy",
"search": "Search",
"confirm": "Confirm",
"dismiss": "Dismiss",
"load": "Load",
"install": "Install",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable",
"back": "Back",
"next": "Next",
"skip": "Skip",
"restart": "Restart"
},
"status": {
"running": "Running",
"stopped": "Stopped",
"error": "Error",
"connected": "Connected",
"disconnected": "Disconnected",
"enabled": "Enabled",
"disabled": "Disabled",
"active": "Active",
"paused": "Paused",
"configured": "Configured",
"loading": "Loading...",
"saving": "Saving..."
},
"gateway": {
"notRunning": "Gateway Not Running",
"notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.",
"warning": "Gateway is not running."
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "Scheduled Tasks",
"subtitle": "Automate AI workflows with scheduled tasks",
"newTask": "New Task",
"gatewayWarning": "Gateway is not running. Scheduled tasks cannot be managed without an active Gateway.",
"stats": {
"total": "Total Tasks",
"active": "Active",
"paused": "Paused",
"failed": "Failed"
},
"empty": {
"title": "No scheduled tasks",
"description": "Create scheduled tasks to automate AI workflows. Tasks can send messages, run queries, or perform actions at specified times.",
"create": "Create Your First Task"
},
"card": {
"runNow": "Run Now",
"deleteConfirm": "Are you sure you want to delete this task?",
"last": "Last",
"next": "Next"
},
"dialog": {
"createTitle": "Create Task",
"editTitle": "Edit Task",
"description": "Schedule an automated AI task",
"taskName": "Task Name",
"taskNamePlaceholder": "e.g., Morning briefing",
"message": "Message / Prompt",
"messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather",
"schedule": "Schedule",
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
"usePresets": "Use presets",
"useCustomCron": "Use custom cron",
"targetChannel": "Target Channel",
"noChannels": "No channels available. Add a channel first.",
"discordChannelId": "Discord Channel ID",
"discordChannelIdPlaceholder": "e.g., 1438452657525100686",
"discordChannelIdDesc": "Right-click the Discord channel → Copy Channel ID",
"enableImmediately": "Enable immediately",
"enableImmediatelyDesc": "Start running this task after creation",
"saveChanges": "Save Changes"
},
"presets": {
"everyMinute": "Every minute",
"every5Min": "Every 5 minutes",
"every15Min": "Every 15 minutes",
"everyHour": "Every hour",
"daily9am": "Daily at 9am",
"daily6pm": "Daily at 6pm",
"weeklyMon": "Weekly (Mon 9am)",
"monthly1st": "Monthly (1st at 9am)"
},
"toast": {
"created": "Task created",
"updated": "Task updated",
"enabled": "Task enabled",
"paused": "Task paused",
"deleted": "Task deleted",
"triggered": "Task triggered successfully",
"failedUpdate": "Failed to update task",
"failedDelete": "Failed to delete task",
"nameRequired": "Please enter a task name",
"messageRequired": "Please enter a message",
"channelRequired": "Please select a channel",
"discordIdRequired": "Please enter a Discord Channel ID",
"scheduleRequired": "Please select or enter a schedule"
}
}

View File

@@ -0,0 +1,28 @@
{
"gateway": "Gateway",
"channels": "Channels",
"skills": "Skills",
"uptime": "Uptime",
"port": "Port: {{port}}",
"pid": "PID: {{pid}}",
"connectedOf": "{{connected}} of {{total}} connected",
"enabledOf": "{{enabled}} of {{total}} enabled",
"sinceRestart": "Since last restart",
"gatewayNotRunning": "Gateway not running",
"quickActions": {
"title": "Quick Actions",
"description": "Common tasks and shortcuts",
"addChannel": "Add Channel",
"browseSkills": "Browse Skills",
"openChat": "Open Chat",
"settings": "Settings",
"devConsole": "Dev Console"
},
"connectedChannels": "Connected Channels",
"noChannels": "No channels configured",
"addFirst": "Add your first channel",
"activeSkills": "Active Skills",
"noSkills": "No skills enabled",
"enableSome": "Enable some skills",
"more": "+{{count}} more"
}

View File

@@ -0,0 +1,134 @@
{
"title": "Settings",
"subtitle": "Configure your ClawX experience",
"appearance": {
"title": "Appearance",
"description": "Customize the look and feel",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"language": "Language"
},
"aiProviders": {
"title": "AI Providers",
"description": "Configure your AI model providers and API keys",
"add": "Add Provider",
"empty": {
"title": "No providers configured",
"desc": "Add an AI provider to start using ClawX",
"cta": "Add Your First Provider"
},
"dialog": {
"title": "Add AI Provider",
"desc": "Configure a new AI model provider",
"displayName": "Display Name",
"apiKey": "API Key",
"apiKeyStored": "Your API key is stored locally on your machine.",
"baseUrl": "Base URL",
"modelId": "Model ID",
"cancel": "Cancel",
"change": "Change provider",
"add": "Add Provider",
"save": "Save",
"validate": "Validate"
},
"card": {
"default": "Default",
"configured": "Configured",
"noKey": "No API key set",
"setDefault": "Set as default",
"editKey": "Edit API key",
"delete": "Delete provider"
},
"toast": {
"added": "Provider added successfully",
"failedAdd": "Failed to add provider",
"deleted": "Provider deleted",
"failedDelete": "Failed to delete provider",
"defaultUpdated": "Default provider updated",
"failedDefault": "Failed to set default",
"updated": "Provider updated",
"failedUpdate": "Failed to update provider",
"invalidKey": "Invalid API key",
"modelRequired": "Model ID is required"
}
},
"gateway": {
"title": "Gateway",
"description": "OpenClaw Gateway settings",
"status": "Status",
"port": "Port",
"logs": "Logs",
"appLogs": "Application Logs",
"openFolder": "Open Folder",
"autoStart": "Auto-start Gateway",
"autoStartDesc": "Start Gateway when ClawX launches"
},
"updates": {
"title": "Updates",
"description": "Keep ClawX up to date",
"autoCheck": "Auto-check for updates",
"autoCheckDesc": "Check for updates on startup",
"autoDownload": "Auto-download updates",
"autoDownloadDesc": "Download updates in the background",
"status": {
"checking": "Checking for updates...",
"downloading": "Downloading update...",
"available": "Update available: v{{version}}",
"downloaded": "Ready to install: v{{version}}",
"failed": "Update check failed",
"latest": "You have the latest version",
"check": "Check for updates to get the latest features"
},
"action": {
"checking": "Checking...",
"downloading": "Downloading...",
"download": "Download Update",
"install": "Install & Restart",
"retry": "Retry",
"check": "Check for Updates"
},
"currentVersion": "Current Version",
"whatsNew": "What's New:",
"errorDetails": "Error Details:",
"help": "Updates are downloaded in the background and installed when you restart the app."
},
"advanced": {
"title": "Advanced",
"description": "Power-user options",
"devMode": "Developer Mode",
"devModeDesc": "Show developer tools and shortcuts"
},
"developer": {
"title": "Developer",
"description": "Advanced options for developers",
"console": "OpenClaw Console",
"consoleDesc": "Access the native OpenClaw management interface",
"openConsole": "Open Developer Console",
"consoleNote": "Opens the Control UI with gateway token injected",
"gatewayToken": "Gateway Token",
"gatewayTokenDesc": "Paste this into Control UI settings if prompted",
"tokenUnavailable": "Token unavailable",
"tokenCopied": "Gateway token copied",
"cli": "OpenClaw CLI",
"cliDesc": "Copy a command to run OpenClaw without modifying PATH.",
"cliPowershell": "PowerShell command.",
"cmdUnavailable": "Command unavailable",
"cmdCopied": "CLI command copied",
"installCmd": "Install \"openclaw\" Command",
"installCmdDesc": "Installs ~/.local/bin/openclaw (no admin required)",
"installTitle": "Install OpenClaw Command",
"installMessage": "Install the \"openclaw\" command?",
"installDetail": "This will create ~/.local/bin/openclaw. Ensure ~/.local/bin is on your PATH if you want to run it globally."
},
"about": {
"title": "About",
"appName": "ClawX",
"tagline": "Graphical AI Assistant",
"basedOn": "Based on OpenClaw",
"version": "Version {{version}}",
"docs": "Documentation",
"github": "GitHub"
}
}

View File

@@ -0,0 +1,119 @@
{
"steps": {
"welcome": {
"title": "Welcome to ClawX",
"description": "Your AI assistant is ready to be configured"
},
"runtime": {
"title": "Environment Check",
"description": "Verifying system requirements"
},
"provider": {
"title": "AI Provider",
"description": "Configure your AI service"
},
"channel": {
"title": "Connect a Channel",
"description": "Connect a messaging platform (optional)"
},
"installing": {
"title": "Setting Up",
"description": "Installing essential components"
},
"complete": {
"title": "All Set!",
"description": "ClawX is ready to use"
}
},
"welcome": {
"title": "Welcome to ClawX",
"description": "ClawX is a graphical interface for OpenClaw, making it easy to use AI assistants across your favorite messaging platforms.",
"features": {
"noCommand": "Zero command-line required",
"modernUI": "Modern, beautiful interface",
"bundles": "Pre-installed skill bundles",
"crossPlatform": "Cross-platform support"
}
},
"runtime": {
"title": "Checking Environment",
"viewLogs": "View Logs",
"recheck": "Re-check",
"nodejs": "Node.js Runtime",
"openclaw": "OpenClaw Package",
"gateway": "Gateway Service",
"startGateway": "Start Gateway",
"status": {
"checking": "Checking...",
"success": "Node.js is available",
"error": "Failed",
"gatewayRunning": "Running on port {{port}}",
"packageReady": "OpenClaw package ready"
},
"issue": {
"title": "Environment issue detected",
"desc": "Please ensure OpenClaw is properly installed. Check the logs for details."
},
"logs": {
"title": "Application Logs",
"openFolder": "Open Log Folder",
"close": "Close",
"noLogs": "(No logs available yet)"
}
},
"provider": {
"label": "Model Provider",
"selectPlaceholder": "Select a provider...",
"baseUrl": "Base URL",
"modelId": "Model ID",
"modelIdDesc": "The model identifier from your provider (e.g. deepseek-ai/DeepSeek-V3)",
"apiKey": "API Key",
"save": "Save",
"validateSave": "Validate & Save",
"valid": "Provider configured successfully",
"invalid": "Invalid API key",
"storedLocally": "Your API key is stored locally on your machine."
},
"channel": {
"title": "Connect a Messaging Channel",
"subtitle": "Choose a platform to connect your AI assistant to. You can add more channels later in Settings.",
"configure": "Configure {{name}}",
"howTo": "How to connect:",
"viewDocs": "View docs",
"validationError": "Validation failed",
"connected": "{{name}} Connected",
"connectedDesc": "Your channel has been configured. It will connect when the Gateway starts.",
"configureAnother": "Configure another channel"
},
"installing": {
"title": "Installing Essential Components",
"subtitle": "Setting up the tools needed to power your AI assistant",
"progress": "Progress",
"status": {
"pending": "Pending",
"installing": "Installing...",
"installed": "Installed",
"failed": "Failed"
},
"error": "Setup Error:",
"restart": "Try restarting the app",
"wait": "This may take a few moments...",
"skip": "Skip this step"
},
"complete": {
"title": "Setup Complete!",
"subtitle": "ClawX is configured and ready to use. You can now start chatting with your AI assistant.",
"provider": "AI Provider",
"components": "Components",
"gateway": "Gateway",
"running": "Running",
"footer": "You can customize skills and connect channels in Settings"
},
"nav": {
"next": "Next",
"back": "Back",
"skipStep": "Skip this step",
"skipSetup": "Skip Setup",
"getStarted": "Get Started"
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "Skills",
"subtitle": "Browse and manage AI capabilities",
"refresh": "Refresh",
"openFolder": "Open Skills Folder",
"gatewayWarning": "Gateway is not running. Skills cannot be loaded without an active Gateway.",
"tabs": {
"installed": "Installed",
"marketplace": "Marketplace"
},
"filter": {
"all": "All ({{count}})",
"builtIn": "Built-in ({{count}})",
"marketplace": "Marketplace ({{count}})"
},
"search": "Search skills...",
"searchMarketplace": "Search marketplace...",
"searchButton": "Search",
"noSkills": "No skills found",
"noSkillsSearch": "Try a different search term",
"noSkillsAvailable": "No skills available",
"detail": {
"info": "Information",
"config": "Configuration",
"description": "Description",
"version": "Version",
"author": "Author",
"source": "Source",
"coreSystem": "Core System",
"bundled": "Bundled",
"userInstalled": "User Installed",
"enabled": "Enabled",
"disabled": "Disabled",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter API Key (optional)",
"apiKeyDesc": "The primary API key for this skill. Leave blank if not required or configured elsewhere.",
"envVars": "Environment Variables",
"addVariable": "Add Variable",
"noEnvVars": "No environment variables configured.",
"keyPlaceholder": "KEY (e.g. BASE_URL)",
"valuePlaceholder": "VALUE",
"envNote": "Note: Rows with empty keys will be automatically removed during save.",
"saving": "Saving...",
"saveConfig": "Save Configuration",
"configSaved": "Configuration saved",
"openManual": "Open Manual",
"configurable": "Configurable"
},
"toast": {
"enabled": "Skill enabled",
"disabled": "Skill disabled",
"installed": "Skill installed and enabled",
"uninstalled": "Skill uninstalled successfully",
"openedEditor": "Opened in editor",
"failedEditor": "Failed to open editor",
"failedSave": "Failed to save configuration",
"failedOpenFolder": "Failed to open skills folder",
"failedInstall": "Failed to install",
"failedUninstall": "Failed to uninstall"
},
"marketplace": {
"title": "Marketplace",
"securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.",
"searching": "Searching ClawHub...",
"noResults": "No skills found matching your search.",
"emptyPrompt": "Search for new skills to expand your capabilities.",
"searchError": "ClawHub search failed. Check your connection or installation."
}
}

View File

@@ -0,0 +1,252 @@
{
"title": "メッセージングチャンネル",
"subtitle": "メッセージングチャンネルと接続を管理",
"refresh": "更新",
"addChannel": "チャンネルを追加",
"stats": {
"total": "全チャンネル",
"connected": "接続済み",
"disconnected": "未接続"
},
"gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。",
"available": "利用可能なチャンネル",
"availableDesc": "新しいチャンネルを接続",
"showAll": "すべて表示",
"pluginBadge": "プラグイン",
"toast": {
"whatsappConnected": "WhatsApp が正常に接続されました",
"whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}",
"channelSaved": "チャンネル {{name}} が保存されました",
"channelConnecting": "{{name}} に接続中...",
"restartManual": "ゲートウェイを手動で再起動してください",
"configFailed": "設定に失敗しました: {{error}}"
},
"dialog": {
"updateTitle": "{{name}} を更新",
"configureTitle": "{{name}} を設定",
"addTitle": "チャンネルを追加",
"existingDesc": "既存の設定を更新します",
"selectDesc": "設定するチャンネルタイプを選択してください",
"qrCode": "QRコード",
"token": "トークン",
"scanQR": "{{name}} でこのQRコードをスキャンしてください",
"refreshCode": "コードを更新",
"loadingConfig": "設定を読み込み中...",
"existingHint": "このチャンネルの設定はすでに存在します",
"howToConnect": "接続方法",
"viewDocs": "ドキュメントを表示",
"channelName": "チャンネル名",
"channelNamePlaceholder": "マイ {{name}}",
"credentialsVerified": "認証情報が確認されました",
"validationFailed": "検証に失敗しました",
"warnings": "警告",
"back": "戻る",
"validating": "検証中...",
"validateConfig": "設定を検証",
"generatingQR": "QRコードを生成中...",
"validatingAndSaving": "検証して保存中...",
"generateQRCode": "QRコードを生成",
"updateAndReconnect": "更新して再接続",
"saveAndConnect": "保存して接続",
"envVar": "環境変数: {{var}}"
},
"meta": {
"telegram": {
"description": "@BotFather からのボットトークンを使用して Telegram に接続します",
"fields": {
"botToken": {
"label": "ボットトークン",
"placeholder": "123456:ABC-DEF..."
},
"allowedUsers": {
"label": "許可されたユーザーID",
"placeholder": "例: 123456789, 987654321",
"description": "ボットの使用を許可するユーザーIDのリストカンマ区切り。セキュリティのため必須です。"
}
},
"instructions": [
"Telegramを開き、@BotFatherを検索します",
"/newbot を送信し、指示に従います",
"提供されたボットトークンをコピーします",
"トークンを以下に貼り付けます",
"@userinfobot からユーザーIDを取得し、以下に貼り付けます"
]
},
"discord": {
"description": "Developer Portal からのボットトークンを使用して Discord に接続します",
"fields": {
"token": {
"label": "ボットトークン",
"placeholder": "Discord ボットトークン"
},
"guildId": {
"label": "サーバーID",
"placeholder": "例: 123456789012345678",
"description": "ボットを特定のサーバーに制限します。サーバーを右クリック → サーバーIDをコピー。"
},
"channelId": {
"label": "チャンネルID (任意)",
"placeholder": "例: 123456789012345678",
"description": "ボットを特定のチャンネルに制限します。チャンネルを右クリック → チャンネルIDをコピー。"
}
},
"instructions": [
"Discord Developer Portal → Applications → New Application に移動します",
"Bot セクションで: Add Bot をクリックし、Bot Token をコピーします",
"Bot → Privileged Gateway Intents で Message Content Intent と Server Members Intent を有効にします",
"OAuth2 → URL Generator で: \"bot\" + \"applications.commands\" を選択し、メッセージ権限を追加します",
"生成された URL を使用してボットをサーバーに招待します",
"以下にボットトークンを貼り付けます"
]
},
"whatsapp": {
"description": "QRコードをスキャンして WhatsApp に接続します(電話番号は不要です)",
"instructions": [
"携帯電話で WhatsApp を開きます",
"設定 > リンクされたデバイス > デバイスをリンク に移動します",
"以下に表示されるQRコードをスキャンします",
"システムが自動的に電話番号を識別します"
]
},
"signal": {
"description": "signal-cli を使用して Signal に接続します",
"fields": {
"phoneNumber": {
"label": "電話番号",
"placeholder": "+1234567890"
}
},
"instructions": [
"システムに signal-cli をインストールします",
"電話番号を登録またはリンクします",
"以下に電話番号を入力します"
]
},
"feishu": {
"description": "WebSocket 経由で Feishu/Lark ボットに接続します",
"fields": {
"appId": {
"label": "App ID",
"placeholder": "cli_xxxxxx"
},
"appSecret": {
"label": "App Secret",
"placeholder": "アプリのシークレット"
}
},
"instructions": [
"Feishu Open Platform に移動します",
"新しいアプリケーションを作成します",
"App ID と App Secret を取得します",
"イベント購読を設定します"
]
},
"imessage": {
"description": "BlueBubbles (macOS) 経由で iMessage に接続します",
"fields": {
"serverUrl": {
"label": "BlueBubbles サーバーURL",
"placeholder": "http://localhost:1234"
},
"password": {
"label": "サーバーパスワード",
"placeholder": "サーバーのパスワード"
}
},
"instructions": [
"Mac に BlueBubbles サーバーをインストールします",
"サーバーURLとパスワードをメモします",
"以下に接続詳細を入力します"
]
},
"matrix": {
"description": "Matrix プロトコルに接続します",
"fields": {
"homeserver": {
"label": "ホームサーバー URL",
"placeholder": "https://matrix.org"
},
"accessToken": {
"label": "アクセストークン",
"placeholder": "アクセストークン"
}
},
"instructions": [
"Matrix アカウントを作成するか、既存のものを使用します",
"クライアントからアクセストークンを取得します",
"以下にホームサーバーとトークンを入力します"
]
},
"line": {
"description": "LINE Messaging API に接続します",
"fields": {
"channelAccessToken": {
"label": "チャンネルアクセストークン",
"placeholder": "LINE チャンネルアクセストークン"
},
"channelSecret": {
"label": "チャンネルシークレット",
"placeholder": "LINE チャンネルシークレット"
}
},
"instructions": [
"LINE Developers Console に移動します",
"Messaging API チャンネルを作成します",
"チャンネルアクセストークンとシークレットを取得します"
]
},
"msteams": {
"description": "Bot Framework 経由で Microsoft Teams に接続します",
"fields": {
"appId": {
"label": "App ID",
"placeholder": "Microsoft App ID"
},
"appPassword": {
"label": "App Password",
"placeholder": "Microsoft App Password"
}
},
"instructions": [
"Azure Portal に移動します",
"新しい Bot アプリケーションを登録します",
"App ID を取得し、パスワードを作成します",
"Teams チャンネルを設定します"
]
},
"googlechat": {
"description": "Webhook 経由で Google Chat に接続します",
"fields": {
"serviceAccountKey": {
"label": "サービスアカウント JSON パス",
"placeholder": "/path/to/service-account.json"
}
},
"instructions": [
"Google Cloud プロジェクトを作成します",
"Google Chat API を有効にします",
"サービスアカウントを作成します",
"JSON キーファイルをダウンロードします"
]
},
"mattermost": {
"description": "Bot API 経由で Mattermost に接続します",
"fields": {
"serverUrl": {
"label": "サーバー URL",
"placeholder": "https://your-mattermost.com"
},
"botToken": {
"label": "ボットアクセストークン",
"placeholder": "ボットアクセストークン"
}
},
"instructions": [
"Mattermost Integrations に移動します",
"新しい Bot アカウントを作成します",
"アクセストークンをコピーします"
]
}
},
"viewDocs": "ドキュメントを表示"
}

View File

@@ -0,0 +1,13 @@
{
"gatewayNotRunning": "ゲートウェイが停止中",
"gatewayRequired": "チャットを使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。",
"welcome": {
"title": "ClawX チャット",
"subtitle": "AI アシスタントの準備ができました。下の入力欄から会話を始めましょう。",
"askQuestions": "質問する",
"askQuestionsDesc": "あらゆるトピックについて回答を得る",
"creativeTasks": "クリエイティブタスク",
"creativeTasksDesc": "ライティング、ブレスト、アイデア"
},
"noLogs": "(ログはまだありません)"
}

View File

@@ -0,0 +1,51 @@
{
"sidebar": {
"chat": "チャット",
"cronTasks": "定期タスク",
"skills": "スキル",
"channels": "チャンネル",
"dashboard": "ダッシュボード",
"settings": "設定",
"devConsole": "開発者コンソール"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"refresh": "更新",
"close": "閉じる",
"copy": "コピー",
"search": "検索",
"confirm": "確認",
"dismiss": "閉じる",
"load": "読み込み",
"install": "インストール",
"uninstall": "アンインストール",
"enable": "有効にする",
"disable": "無効にする",
"back": "戻る",
"next": "次へ",
"skip": "スキップ",
"restart": "再起動"
},
"status": {
"running": "実行中",
"stopped": "停止",
"error": "エラー",
"connected": "接続済み",
"disconnected": "切断",
"enabled": "有効",
"disabled": "無効",
"active": "アクティブ",
"paused": "一時停止",
"configured": "設定済み",
"loading": "読み込み中...",
"saving": "保存中..."
},
"gateway": {
"notRunning": "ゲートウェイが停止中",
"notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。",
"warning": "ゲートウェイが停止中です。"
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "定期タスク",
"subtitle": "定期タスクでAIワークフローを自動化",
"newTask": "新規タスク",
"gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないと定期タスクを管理できません。",
"stats": {
"total": "タスク合計",
"active": "有効",
"paused": "停止中",
"failed": "失敗"
},
"empty": {
"title": "定期タスクがありません",
"description": "定期タスクを作成してAIワークフローを自動化します。指定した時間にメッセージ送信、クエリ実行、アクション実行が可能です。",
"create": "最初のタスクを作成"
},
"card": {
"runNow": "今すぐ実行",
"deleteConfirm": "このタスクを削除してもよろしいですか?",
"last": "前回",
"next": "次回"
},
"dialog": {
"createTitle": "タスク作成",
"editTitle": "タスク編集",
"description": "自動化AIタスクをスケジュール",
"taskName": "タスク名",
"taskNamePlaceholder": "例:朝のブリーフィング",
"message": "メッセージ / プロンプト",
"messagePlaceholder": "AIに何をさせますか 例:今日のニュースと天気のまとめを作成",
"schedule": "スケジュール",
"cronPlaceholder": "Cron式0 9 * * *",
"usePresets": "プリセットを使用",
"useCustomCron": "カスタムCronを使用",
"targetChannel": "ターゲットチャンネル",
"noChannels": "利用可能なチャンネルがありません。先にチャンネルを追加してください。",
"discordChannelId": "DiscordチャンネルID",
"discordChannelIdPlaceholder": "例1438452657525100686",
"discordChannelIdDesc": "Discordチャンネルを右クリック → チャンネルIDをコピー",
"enableImmediately": "すぐに有効化",
"enableImmediatelyDesc": "作成後すぐにこのタスクを実行開始",
"saveChanges": "変更を保存"
},
"presets": {
"everyMinute": "毎分",
"every5Min": "5分ごと",
"every15Min": "15分ごと",
"everyHour": "1時間ごと",
"daily9am": "毎日 午前9時",
"daily6pm": "毎日 午後6時",
"weeklyMon": "毎週 (月曜 午前9時)",
"monthly1st": "毎月 (1日 午前9時)"
},
"toast": {
"created": "タスクを作成しました",
"updated": "タスクを更新しました",
"enabled": "タスクを有効にしました",
"paused": "タスクを停止しました",
"deleted": "タスクを削除しました",
"triggered": "タスクを正常にトリガーしました",
"failedUpdate": "タスクの更新に失敗しました",
"failedDelete": "タスクの削除に失敗しました",
"nameRequired": "タスク名を入力してください",
"messageRequired": "メッセージを入力してください",
"channelRequired": "チャンネルを選択してください",
"discordIdRequired": "DiscordチャンネルIDを入力してください",
"scheduleRequired": "スケジュールを選択または入力してください"
}
}

View File

@@ -0,0 +1,28 @@
{
"gateway": "ゲートウェイ",
"channels": "チャンネル",
"skills": "スキル",
"uptime": "稼働時間",
"port": "ポート: {{port}}",
"pid": "PID: {{pid}}",
"connectedOf": "{{total}} 中 {{connected}} 接続済み",
"enabledOf": "{{total}} 中 {{enabled}} 有効",
"sinceRestart": "前回の再起動から",
"gatewayNotRunning": "ゲートウェイが停止中",
"quickActions": {
"title": "クイックアクション",
"description": "よく使うタスクとショートカット",
"addChannel": "チャンネル追加",
"browseSkills": "スキルを探す",
"openChat": "チャットを開く",
"settings": "設定",
"devConsole": "開発者コンソール"
},
"connectedChannels": "接続済みチャンネル",
"noChannels": "チャンネルが設定されていません",
"addFirst": "最初のチャンネルを追加",
"activeSkills": "アクティブなスキル",
"noSkills": "有効なスキルがありません",
"enableSome": "スキルを有効にする",
"more": "+{{count}} 件"
}

View File

@@ -0,0 +1,113 @@
{
"title": "設定",
"subtitle": "ClawX の体験をカスタマイズ",
"appearance": {
"title": "外観",
"description": "外観とスタイルをカスタマイズ",
"theme": "テーマ",
"light": "ライト",
"dark": "ダーク",
"system": "システム",
"language": "言語"
},
"aiProviders": {
"title": "AI プロバイダー",
"description": "AI モデルプロバイダーと API キーを設定",
"add": "プロバイダーを追加",
"empty": {
"title": "プロバイダーが構成されていません",
"desc": "ClawX の使用を開始するには AI プロバイダーを追加してください",
"cta": "最初のプロバイダーを追加"
},
"dialog": {
"title": "AI プロバイダーを追加",
"desc": "新しい AI モデルプロバイダーを構成",
"displayName": "表示名",
"apiKey": "API キー",
"apiKeyStored": "API キーはローカルマシンに保存されます。",
"baseUrl": "ベース URL",
"modelId": "モデル ID",
"cancel": "キャンセル",
"change": "プロバイダーを変更",
"add": "プロバイダーを追加",
"save": "保存",
"validate": "検証"
},
"card": {
"default": "デフォルト",
"configured": "構成済み",
"noKey": "API キー未設定",
"setDefault": "デフォルトに設定",
"editKey": "API キーを編集",
"delete": "プロバイダーを削除"
},
"toast": {
"added": "プロバイダーが正常に追加されました",
"failedAdd": "プロバイダーの追加に失敗しました",
"deleted": "プロバイダーが削除されました",
"failedDelete": "プロバイダーの削除に失敗しました",
"defaultUpdated": "デフォルトプロバイダーが更新されました",
"failedDefault": "デフォルトの設定に失敗しました",
"updated": "プロバイダーが更新されました",
"failedUpdate": "プロバイダーの更新に失敗しました",
"invalidKey": "無効な API キー",
"modelRequired": "モデル ID が必要です"
}
},
"gateway": {
"title": "ゲートウェイ",
"description": "OpenClaw ゲートウェイ設定",
"status": "ステータス",
"port": "ポート",
"logs": "ログ",
"appLogs": "アプリケーションログ",
"openFolder": "フォルダーを開く",
"autoStart": "ゲートウェイ自動起動",
"autoStartDesc": "ClawX 起動時にゲートウェイを自動起動"
},
"updates": {
"title": "アップデート",
"description": "ClawX を最新に保つ",
"autoCheck": "自動更新チェック",
"autoCheckDesc": "起動時に更新を確認",
"autoDownload": "自動ダウンロード",
"autoDownloadDesc": "バックグラウンドで更新をダウンロード"
},
"advanced": {
"title": "詳細設定",
"description": "上級ユーザー向けオプション",
"devMode": "開発者モード",
"devModeDesc": "開発者ツールとショートカットを表示"
},
"developer": {
"title": "開発者",
"description": "開発者向け詳細オプション",
"console": "OpenClaw コンソール",
"consoleDesc": "ネイティブ OpenClaw 管理インターフェースにアクセス",
"openConsole": "開発者コンソールを開く",
"consoleNote": "ゲートウェイトークンを注入して Control UI を開きます",
"gatewayToken": "ゲートウェイトークン",
"gatewayTokenDesc": "Control UI の設定に求められた場合、これを貼り付けてください",
"tokenUnavailable": "トークンが利用できません",
"tokenCopied": "ゲートウェイトークンをコピーしました",
"cli": "OpenClaw CLI",
"cliDesc": "PATH を変更せずに OpenClaw を実行するコマンドをコピー。",
"cliPowershell": "PowerShell コマンド。",
"cmdUnavailable": "コマンドが利用できません",
"cmdCopied": "CLI コマンドをコピーしました",
"installCmd": "\"openclaw\" コマンドをインストール",
"installCmdDesc": "~/.local/bin/openclaw をインストール(管理者権限不要)",
"installTitle": "OpenClaw コマンドをインストール",
"installMessage": "\"openclaw\" コマンドをインストールしますか?",
"installDetail": "~/.local/bin/openclaw が作成されます。グローバルに実行するには ~/.local/bin が PATH に含まれていることを確認してください。"
},
"about": {
"title": "バージョン情報",
"appName": "ClawX",
"tagline": "グラフィカル AI アシスタント",
"basedOn": "OpenClaw ベース",
"version": "バージョン {{version}}",
"docs": "ドキュメント",
"github": "GitHub"
}
}

View File

@@ -0,0 +1,119 @@
{
"steps": {
"welcome": {
"title": "ClawXへようこそ",
"description": "AIアシスタントの設定準備が整いました"
},
"runtime": {
"title": "環境チェック",
"description": "システム要件を確認中"
},
"provider": {
"title": "AIプロバイダー",
"description": "AIサービスを構成"
},
"channel": {
"title": "チャンネル接続",
"description": "メッセージングプラットフォームを接続(オプション)"
},
"installing": {
"title": "セットアップ中",
"description": "必須コンポーネントをインストール中"
},
"complete": {
"title": "完了!",
"description": "ClawXを使用する準備が整いました"
}
},
"welcome": {
"title": "ClawXへようこそ",
"description": "ClawXはOpenClawのグラフィカルインターフェースで、お気に入りのメッセージングプラットフォームでAIアシスタントを簡単に使用できます。",
"features": {
"noCommand": "コマンドライン不要",
"modernUI": "モダンで美しいインターフェース",
"bundles": "プリインストールされたスキルバンドル",
"crossPlatform": "クロスプラットフォーム対応"
}
},
"runtime": {
"title": "環境をチェック中",
"viewLogs": "ログを表示",
"recheck": "再チェック",
"nodejs": "Node.js ランタイム",
"openclaw": "OpenClaw パッケージ",
"gateway": "ゲートウェイサービス",
"startGateway": "ゲートウェイを起動",
"status": {
"checking": "確認中...",
"success": "Node.js は利用可能です",
"error": "失敗",
"gatewayRunning": "ポート {{port}} で実行中",
"packageReady": "OpenClaw パッケージ準備完了"
},
"issue": {
"title": "環境の問題が検出されました",
"desc": "OpenClawが正しくインストールされていることを確認してください。詳細はログを確認してください。"
},
"logs": {
"title": "アプリケーションログ",
"openFolder": "ログフォルダを開く",
"close": "閉じる",
"noLogs": "(ログはまだありません)"
}
},
"provider": {
"label": "モデルプロバイダー",
"selectPlaceholder": "プロバイダーを選択...",
"baseUrl": "ベース URL",
"modelId": "モデル ID",
"modelIdDesc": "プロバイダーのモデル識別子deepseek-ai/DeepSeek-V3",
"apiKey": "API キー",
"save": "保存",
"validateSave": "検証して保存",
"valid": "プロバイダーが正常に構成されました",
"invalid": "無効な API キー",
"storedLocally": "API キーはローカルマシンに保存されます。"
},
"channel": {
"title": "メッセージングチャンネルを接続",
"subtitle": "AIアシスタントを接続するプラットフォームを選択してください。チャンネルは後で設定で追加できます。",
"configure": "{{name}} を構成",
"howTo": "接続方法:",
"viewDocs": "ドキュメントを表示",
"validationError": "検証に失敗しました",
"connected": "{{name}} 接続済み",
"connectedDesc": "チャンネルが構成されました。ゲートウェイ起動時に接続されます。",
"configureAnother": "別のチャンネルを構成"
},
"installing": {
"title": "必須コンポーネントをインストール中",
"subtitle": "AIアシスタントに必要なツールをセットアップしています",
"progress": "進捗",
"status": {
"pending": "待機中",
"installing": "インストール中...",
"installed": "インストール済み",
"failed": "失敗"
},
"error": "セットアップエラー:",
"restart": "アプリを再起動してください",
"wait": "これには少し時間がかかる場合があります...",
"skip": "この手順をスキップ"
},
"complete": {
"title": "セットアップ完了!",
"subtitle": "ClawXの構成が完了し、使用準備が整いました。AIアシスタントとのチャットを開始できます。",
"provider": "AI プロバイダー",
"components": "コンポーネント",
"gateway": "ゲートウェイ",
"running": "実行中",
"footer": "設定でスキルをカスタマイズしたり、チャンネルを接続したりできます"
},
"nav": {
"next": "次へ",
"back": "戻る",
"skipStep": "この手順をスキップ",
"skipSetup": "セットアップをスキップ",
"getStarted": "始める"
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "スキル",
"subtitle": "AI機能の閲覧と管理",
"refresh": "更新",
"openFolder": "スキルフォルダを開く",
"gatewayWarning": "ゲートウェイが稼働していません。アクティブなゲートウェイがないとスキルを読み込めません。",
"tabs": {
"installed": "インストール済み",
"marketplace": "マーケットプレイス"
},
"filter": {
"all": "すべて ({{count}})",
"builtIn": "内蔵 ({{count}})",
"marketplace": "マーケットプレイス ({{count}})"
},
"search": "スキルを検索...",
"searchMarketplace": "マーケットプレイスを検索...",
"searchButton": "検索",
"noSkills": "スキルが見つかりません",
"noSkillsSearch": "別の検索語をお試しください",
"noSkillsAvailable": "利用可能なスキルがありません",
"detail": {
"info": "情報",
"config": "設定",
"description": "説明",
"version": "バージョン",
"author": "作者",
"source": "ソース",
"coreSystem": "コアシステム",
"bundled": "内蔵",
"userInstalled": "ユーザーインストール",
"enabled": "有効",
"disabled": "無効",
"apiKey": "APIキー",
"apiKeyPlaceholder": "APIキーを入力任意",
"apiKeyDesc": "このスキルの主要なAPIキーです。不要な場合または別の場所で設定している場合は空白のままにしてください。",
"envVars": "環境変数",
"addVariable": "変数を追加",
"noEnvVars": "環境変数が設定されていません。",
"keyPlaceholder": "キーBASE_URL",
"valuePlaceholder": "値",
"envNote": "注意:キーが空の行は保存時に自動的に削除されます。",
"saving": "保存中...",
"saveConfig": "設定を保存",
"configSaved": "設定を保存しました",
"openManual": "マニュアルを開く",
"configurable": "設定可能"
},
"toast": {
"enabled": "スキルを有効にしました",
"disabled": "スキルを無効にしました",
"installed": "スキルをインストールして有効にしました",
"uninstalled": "スキルのアンインストールに成功しました",
"openedEditor": "エディターで開きました",
"failedEditor": "エディターを開けませんでした",
"failedSave": "設定の保存に失敗しました",
"failedOpenFolder": "スキルフォルダを開けませんでした",
"failedInstall": "インストールに失敗しました",
"failedUninstall": "アンインストールに失敗しました"
},
"marketplace": {
"title": "マーケットプレイス",
"securityNote": "インストール前にスキルカードをクリックして、ClawHubでドキュメントとセキュリティ情報を確認してください。",
"searching": "ClawHubを検索中...",
"noResults": "検索に一致するスキルが見つかりません。",
"emptyPrompt": "新しいスキルを検索して機能を拡張しましょう。",
"searchError": "ClawHub検索に失敗しました。接続またはインストールを確認してください。"
}
}

View File

@@ -0,0 +1,263 @@
{
"title": "消息频道",
"subtitle": "管理您的消息频道和连接",
"refresh": "刷新",
"addChannel": "添加频道",
"stats": {
"total": "频道总数",
"connected": "已连接",
"disconnected": "未连接"
},
"gatewayWarning": "网关服务未运行,频道无法连接。",
"available": "可用频道",
"availableDesc": "连接一个新的频道",
"showAll": "显示全部",
"pluginBadge": "插件",
"toast": {
"whatsappConnected": "WhatsApp 连接成功",
"whatsappFailed": "WhatsApp 连接失败: {{error}}",
"channelSaved": "频道 {{name}} 已保存",
"channelConnecting": "正在连接 {{name}}...",
"restartManual": "请手动重启网关",
"configFailed": "配置失败: {{error}}"
},
"dialog": {
"updateTitle": "更新 {{name}}",
"configureTitle": "配置 {{name}}",
"addTitle": "添加频道",
"existingDesc": "更新您现有的配置",
"selectDesc": "选择要配置的频道类型",
"qrCode": "二维码",
"token": "令牌",
"scanQR": "使用 {{name}} 扫描此二维码",
"refreshCode": "刷新代码",
"loadingConfig": "正在加载配置...",
"existingHint": "您已配置过此频道",
"howToConnect": "如何连接",
"viewDocs": "查看文档",
"channelName": "频道名称",
"channelNamePlaceholder": "我的 {{name}}",
"credentialsVerified": "凭证已验证",
"validationFailed": "验证失败",
"warnings": "警告",
"back": "返回",
"validating": "正在验证...",
"validateConfig": "验证配置",
"generatingQR": "正在生成二维码...",
"validatingAndSaving": "正在验证并保存...",
"generateQRCode": "生成二维码",
"updateAndReconnect": "更新并重新连接",
"saveAndConnect": "保存并连接",
"envVar": "环境变量: {{var}}"
},
"meta": {
"telegram": {
"description": "使用 @BotFather 提供的机器人令牌连接 Telegram",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/telegram",
"fields": {
"botToken": {
"label": "机器人令牌",
"placeholder": "123456:ABC-DEF..."
},
"allowedUsers": {
"label": "允许的用户 ID",
"placeholder": "例如 123456789, 987654321",
"description": "允许使用机器人的用户 ID 列表(逗号分隔)。出于安全考虑,此项为必填。"
}
},
"instructions": [
"打开 Telegram 并搜索 @BotFather",
"发送 /newbot 并按照说明操作",
"复制提供的机器人令牌",
"在下方粘贴令牌",
"从 @userinfobot 获取您的用户 ID 并粘贴在下方"
]
},
"discord": {
"description": "使用开发者门户提供的机器人令牌连接 Discord",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/discord#%E5%A6%82%E4%BD%95%E5%88%9B%E5%BB%BA%E4%BD%A0%E7%9A%84%E6%9C%BA%E5%99%A8%E4%BA%BA",
"fields": {
"token": {
"label": "机器人令牌",
"placeholder": "您的 Discord 机器人令牌"
},
"guildId": {
"label": "服务器 ID",
"placeholder": "例如 123456789012345678",
"description": "限制机器人仅在特定服务器工作。右键点击服务器 → 复制服务器 ID。"
},
"channelId": {
"label": "频道 ID (可选)",
"placeholder": "例如 123456789012345678",
"description": "限制机器人仅在特定频道工作。右键点击频道 → 复制频道 ID。"
}
},
"instructions": [
"前往 Discord 开发者门户 → Applications → New Application",
"在 Bot 部分:添加 Bot然后复制 Bot Token",
"在 Bot → Privileged Gateway Intents 中启用 Message Content Intent 和 Server Members Intent",
"在 OAuth2 → URL Generator选择 \"bot\" + \"applications.commands\",添加消息权限",
"使用生成的 URL 邀请机器人加入您的服务器",
"在下方粘贴机器人令牌"
]
},
"whatsapp": {
"description": "通过扫描二维码连接 WhatsApp无需手机号",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/whatsapp",
"instructions": [
"在手机上打开 WhatsApp",
"前往 设置 > 已关联设备 >包含连接设备",
"扫描下方显示的二维码",
"系统将自动识别您的手机号"
]
},
"signal": {
"description": "使用 signal-cli 连接 Signal",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/signal",
"fields": {
"phoneNumber": {
"label": "手机号码",
"placeholder": "+1234567890"
}
},
"instructions": [
"在您的系统上安装 signal-cli",
"注册或链接您的手机号码",
"在下方输入您的手机号码"
]
},
"feishu": {
"description": "通过 WebSocket 连接飞书/Lark 机器人",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/feishu#%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E5%88%9B%E5%BB%BA%E9%A3%9E%E4%B9%A6%E5%BA%94%E7%94%A8",
"fields": {
"appId": {
"label": "应用 ID (App ID)",
"placeholder": "cli_xxxxxx"
},
"appSecret": {
"label": "应用密钥 (App Secret)",
"placeholder": "您的应用密钥"
}
},
"instructions": [
"前往飞书开放平台",
"创建一个新应用",
"获取 App ID 和 App Secret",
"配置事件订阅"
]
},
"imessage": {
"description": "通过 BlueBubbles (macOS) 连接 iMessage",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/bluebubbles",
"fields": {
"serverUrl": {
"label": "BlueBubbles 服务器地址",
"placeholder": "http://localhost:1234"
},
"password": {
"label": "服务器密码",
"placeholder": "您的服务器密码"
}
},
"instructions": [
"在您的 Mac 上安装 BlueBubbles 服务器",
"记下服务器地址和密码",
"在下方输入连接详情"
]
},
"matrix": {
"description": "连接到 Matrix 协议",
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/matrix",
"fields": {
"homeserver": {
"label": "Homeserver 地址",
"placeholder": "https://matrix.org"
},
"accessToken": {
"label": "访问令牌 (Access Token)",
"placeholder": "您的访问令牌"
}
},
"instructions": [
"创建一个 Matrix 账户或使用现有账户",
"从您的客户端获取访问令牌",
"在下方输入 Homeserver 地址和令牌"
]
},
"line": {
"description": "连接 LINE Messaging API",
"docsUrl": "https://docs.openclaw.ai/channels/line",
"fields": {
"channelAccessToken": {
"label": "频道访问令牌",
"placeholder": "您的 LINE 频道访问令牌"
},
"channelSecret": {
"label": "频道密钥",
"placeholder": "您的 LINE 频道密钥"
}
},
"instructions": [
"前往 LINE 开发者控制台",
"创建一个 Messaging API 频道",
"获取频道访问令牌和密钥"
]
},
"msteams": {
"description": "通过 Bot Framework 连接 Microsoft Teams",
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
"fields": {
"appId": {
"label": "应用 ID",
"placeholder": "您的 Microsoft 应用 ID"
},
"appPassword": {
"label": "应用密码",
"placeholder": "您的 Microsoft 应用密码"
}
},
"instructions": [
"前往 Azure 门户",
"注册一个新的 Bot 应用",
"获取应用 ID 并创建密码",
"配置 Teams 频道"
]
},
"googlechat": {
"description": "通过 Webhook 连接 Google Chat",
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
"fields": {
"serviceAccountKey": {
"label": "服务账号 JSON 路径",
"placeholder": "/path/to/service-account.json"
}
},
"instructions": [
"创建 Google Cloud 项目",
"启用 Google Chat API",
"创建服务账号",
"下载 JSON 密钥文件"
]
},
"mattermost": {
"description": "通过 Bot API 连接 Mattermost",
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
"fields": {
"serverUrl": {
"label": "服务器地址",
"placeholder": "https://your-mattermost.com"
},
"botToken": {
"label": "机器人访问令牌",
"placeholder": "您的机器人访问令牌"
}
},
"instructions": [
"前往 Mattermost 集成",
"创建一个新的 Bot 账户",
"复制访问令牌"
]
}
},
"viewDocs": "查看文档"
}

View File

@@ -0,0 +1,13 @@
{
"gatewayNotRunning": "网关未运行",
"gatewayRequired": "OpenClaw 网关需要运行才能使用聊天。它将自动启动,或者您可以从设置中启动。",
"welcome": {
"title": "ClawX 聊天",
"subtitle": "您的 AI 助手已就绪。在下方开始对话。",
"askQuestions": "提问",
"askQuestionsDesc": "获取任何话题的答案",
"creativeTasks": "创意任务",
"creativeTasksDesc": "写作、头脑风暴、创意"
},
"noLogs": "(暂无日志)"
}

View File

@@ -0,0 +1,51 @@
{
"sidebar": {
"chat": "聊天",
"cronTasks": "定时任务",
"skills": "技能",
"channels": "频道",
"dashboard": "仪表盘",
"settings": "设置",
"devConsole": "开发者控制台"
},
"actions": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"refresh": "刷新",
"close": "关闭",
"copy": "复制",
"search": "搜索",
"confirm": "确认",
"dismiss": "忽略",
"load": "加载",
"install": "安装",
"uninstall": "卸载",
"enable": "启用",
"disable": "禁用",
"back": "返回",
"next": "下一步",
"skip": "跳过",
"restart": "重启"
},
"status": {
"running": "运行中",
"stopped": "已停止",
"error": "错误",
"connected": "已连接",
"disconnected": "已断开",
"enabled": "已启用",
"disabled": "已禁用",
"active": "活跃",
"paused": "已暂停",
"configured": "已配置",
"loading": "加载中...",
"saving": "保存中..."
},
"gateway": {
"notRunning": "网关未运行",
"notRunningDesc": "OpenClaw 网关需要运行才能使用此功能。它将自动启动,或者您可以从设置中启动。",
"warning": "网关未运行。"
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "定时任务",
"subtitle": "通过定时任务自动化 AI 工作流",
"newTask": "新建任务",
"gatewayWarning": "网关未运行。没有活跃的网关,无法管理定时任务。",
"stats": {
"total": "任务总数",
"active": "运行中",
"paused": "已暂停",
"failed": "失败"
},
"empty": {
"title": "暂无定时任务",
"description": "创建定时任务以自动化 AI 工作流。任务可以在指定时间发送消息、运行查询或执行操作。",
"create": "创建第一个任务"
},
"card": {
"runNow": "立即运行",
"deleteConfirm": "确定要删除此任务吗?",
"last": "上次运行",
"next": "下次运行"
},
"dialog": {
"createTitle": "创建任务",
"editTitle": "编辑任务",
"description": "安排自动化的 AI 任务",
"taskName": "任务名称",
"taskNamePlaceholder": "例如:早间简报",
"message": "消息/提示词",
"messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要",
"schedule": "调度计划",
"cronPlaceholder": "Cron 表达式 (例如0 9 * * *)",
"usePresets": "使用预设",
"useCustomCron": "使用自定义 Cron",
"targetChannel": "目标频道",
"noChannels": "无可用频道。请先添加频道。",
"discordChannelId": "Discord 频道 ID",
"discordChannelIdPlaceholder": "例如1438452657525100686",
"discordChannelIdDesc": "右键点击 Discord 频道 → 复制频道 ID",
"enableImmediately": "立即启用",
"enableImmediatelyDesc": "创建后立即开始运行此任务",
"saveChanges": "保存更改"
},
"presets": {
"everyMinute": "每分钟",
"every5Min": "每 5 分钟",
"every15Min": "每 15 分钟",
"everyHour": "每小时",
"daily9am": "每天上午 9 点",
"daily6pm": "每天下午 6 点",
"weeklyMon": "每周 (周一上午 9 点)",
"monthly1st": "每月 (1号上午 9 点)"
},
"toast": {
"created": "任务已创建",
"updated": "任务已更新",
"enabled": "任务已启用",
"paused": "任务已暂停",
"deleted": "任务已删除",
"triggered": "任务已成功触发",
"failedUpdate": "更新任务失败",
"failedDelete": "删除任务失败",
"nameRequired": "请输入任务名称",
"messageRequired": "请输入消息",
"channelRequired": "请选择频道",
"discordIdRequired": "请输入 Discord 频道 ID",
"scheduleRequired": "请选择或输入调度计划"
}
}

View File

@@ -0,0 +1,28 @@
{
"gateway": "网关",
"channels": "频道",
"skills": "技能",
"uptime": "运行时间",
"port": "端口: {{port}}",
"pid": "PID: {{pid}}",
"connectedOf": "{{connected}} / {{total}} 已连接",
"enabledOf": "{{enabled}} / {{total}} 已启用",
"sinceRestart": "自上次重启",
"gatewayNotRunning": "网关未运行",
"quickActions": {
"title": "快捷操作",
"description": "常用任务和快捷方式",
"addChannel": "添加频道",
"browseSkills": "浏览技能",
"openChat": "打开聊天",
"settings": "设置",
"devConsole": "开发者控制台"
},
"connectedChannels": "已连接频道",
"noChannels": "未配置频道",
"addFirst": "添加你的第一个频道",
"activeSkills": "已启用技能",
"noSkills": "未启用技能",
"enableSome": "启用一些技能",
"more": "+{{count}} 更多"
}

View File

@@ -0,0 +1,134 @@
{
"title": "设置",
"subtitle": "配置您的 ClawX 体验",
"appearance": {
"title": "外观",
"description": "自定义外观和风格",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"language": "语言"
},
"aiProviders": {
"title": "AI 模型提供商",
"description": "配置 AI 模型提供商和 API 密钥",
"add": "添加提供商",
"empty": {
"title": "未配置提供商",
"desc": "添加 AI 提供商以开始使用 ClawX",
"cta": "添加您的第一个提供商"
},
"dialog": {
"title": "添加 AI 提供商",
"desc": "配置新的 AI 模型提供商",
"displayName": "显示名称",
"apiKey": "API 密钥",
"apiKeyStored": "您的 API 密钥存储在本地机器上。",
"baseUrl": "基础 URL",
"modelId": "模型 ID",
"cancel": "取消",
"change": "更换提供商",
"add": "添加提供商",
"save": "保存",
"validate": "验证"
},
"card": {
"default": "默认",
"configured": "已配置",
"noKey": "未设置 API 密钥",
"setDefault": "设为默认",
"editKey": "编辑 API 密钥",
"delete": "删除提供商"
},
"toast": {
"added": "提供商添加成功",
"failedAdd": "添加提供商失败",
"deleted": "提供商已删除",
"failedDelete": "删除提供商失败",
"defaultUpdated": "默认提供商已更新",
"failedDefault": "设置默认失败",
"updated": "提供商已更新",
"failedUpdate": "更新提供商失败",
"invalidKey": "无效的 API 密钥",
"modelRequired": "需要模型 ID"
}
},
"gateway": {
"title": "网关",
"description": "OpenClaw 网关设置",
"status": "状态",
"port": "端口",
"logs": "日志",
"appLogs": "应用日志",
"openFolder": "打开文件夹",
"autoStart": "自动启动网关",
"autoStartDesc": "ClawX 启动时自动启动网关"
},
"updates": {
"title": "更新",
"description": "保持 ClawX 最新",
"autoCheck": "自动检查更新",
"autoCheckDesc": "启动时检查更新",
"autoDownload": "自动下载更新",
"autoDownloadDesc": "在后台下载更新",
"status": {
"checking": "正在检查更新...",
"downloading": "正在下载更新...",
"available": "可用更新v{{version}}",
"downloaded": "准备安装v{{version}}",
"failed": "检查更新失败",
"latest": "您已拥有最新版本",
"check": "检查更新以获取最新功能"
},
"action": {
"checking": "检查中...",
"downloading": "下载中...",
"download": "下载更新",
"install": "安装并重启",
"retry": "重试",
"check": "检查更新"
},
"currentVersion": "当前版本",
"whatsNew": "更新内容:",
"errorDetails": "错误详情:",
"help": "更新将在后台下载,并在您重启应用时安装。"
},
"advanced": {
"title": "高级",
"description": "高级选项",
"devMode": "开发者模式",
"devModeDesc": "显示开发者工具和快捷方式"
},
"developer": {
"title": "开发者",
"description": "开发者高级选项",
"console": "OpenClaw 控制台",
"consoleDesc": "访问原生 OpenClaw 管理界面",
"openConsole": "打开开发者控制台",
"consoleNote": "使用注入的网关令牌打开控制台",
"gatewayToken": "网关令牌",
"gatewayTokenDesc": "如果需要,将此粘贴到控制台设置中",
"tokenUnavailable": "令牌不可用",
"tokenCopied": "网关令牌已复制",
"cli": "OpenClaw CLI",
"cliDesc": "复制命令以运行 OpenClaw无需修改 PATH。",
"cliPowershell": "PowerShell 命令。",
"cmdUnavailable": "命令不可用",
"cmdCopied": "CLI 命令已复制",
"installCmd": "安装 \"openclaw\" 命令",
"installCmdDesc": "安装 ~/.local/bin/openclaw无需管理员权限",
"installTitle": "安装 OpenClaw 命令",
"installMessage": "安装 \"openclaw\" 命令?",
"installDetail": "这将创建 ~/.local/bin/openclaw。确保 ~/.local/bin 在您的 PATH 中。"
},
"about": {
"title": "关于",
"appName": "ClawX",
"tagline": "图形化 AI 助手",
"basedOn": "基于 OpenClaw",
"version": "版本 {{version}}",
"docs": "文档",
"github": "GitHub"
}
}

View File

@@ -0,0 +1,119 @@
{
"steps": {
"welcome": {
"title": "欢迎使用 ClawX",
"description": "您的 AI 助手已准备好进行配置"
},
"runtime": {
"title": "环境检查",
"description": "验证系统要求"
},
"provider": {
"title": "AI 提供商",
"description": "配置您的 AI 服务"
},
"channel": {
"title": "连接渠道",
"description": "连接消息平台(可选)"
},
"installing": {
"title": "设置中",
"description": "安装必要组件"
},
"complete": {
"title": "准备就绪!",
"description": "ClawX 已准备好使用"
}
},
"welcome": {
"title": "欢迎使用 ClawX",
"description": "ClawX 是 OpenClaw 的图形界面,让您可以在喜爱的消息平台上轻松使用 AI 助手。",
"features": {
"noCommand": "无需命令行",
"modernUI": "现代美观的界面",
"bundles": "预装技能包",
"crossPlatform": "跨平台支持"
}
},
"runtime": {
"title": "检查环境",
"viewLogs": "查看日志",
"recheck": "重新检查",
"nodejs": "Node.js 运行时",
"openclaw": "OpenClaw 包",
"gateway": "网关服务",
"startGateway": "启动网关",
"status": {
"checking": "检查中...",
"success": "Node.js 可用",
"error": "失败",
"gatewayRunning": "运行在端口 {{port}}",
"packageReady": "OpenClaw 包已就绪"
},
"issue": {
"title": "检测到环境问题",
"desc": "请确保 OpenClaw 已正确安装。查看日志以获取详情。"
},
"logs": {
"title": "应用程序日志",
"openFolder": "打开日志文件夹",
"close": "关闭",
"noLogs": "(暂无日志)"
}
},
"provider": {
"label": "模型提供商",
"selectPlaceholder": "选择提供商...",
"baseUrl": "基础 URL",
"modelId": "模型 ID",
"modelIdDesc": "提供商的模型标识符(例如 deepseek-ai/DeepSeek-V3",
"apiKey": "API 密钥",
"save": "保存",
"validateSave": "验证并保存",
"valid": "提供商配置成功",
"invalid": "无效的 API 密钥",
"storedLocally": "您的 API 密钥存储在本地机器上。"
},
"channel": {
"title": "连接消息渠道",
"subtitle": "选择要连接 AI 助手的平台。您可以稍后在设置中添加更多渠道。",
"configure": "配置 {{name}}",
"howTo": "如何连接:",
"viewDocs": "查看文档",
"validationError": "验证失败",
"connected": "{{name}} 已连接",
"connectedDesc": "您的渠道已配置。将在网关启动时连接。",
"configureAnother": "配置另一个渠道"
},
"installing": {
"title": "安装必要组件",
"subtitle": "正在设置 AI 助手所需的工具",
"progress": "进度",
"status": {
"pending": "等待中",
"installing": "安装中...",
"installed": "已安装",
"failed": "失败"
},
"error": "设置错误:",
"restart": "尝试重启应用",
"wait": "这可能需要一点时间...",
"skip": "跳过此步骤"
},
"complete": {
"title": "设置完成!",
"subtitle": "ClawX 已配置并准备就绪。您现在可以开始与您的 AI 助手聊天了。",
"provider": "AI 提供商",
"components": "组件",
"gateway": "网关",
"running": "运行中",
"footer": "您可以在设置中自定义技能并连接渠道"
},
"nav": {
"next": "下一步",
"back": "返回",
"skipStep": "跳过此步骤",
"skipSetup": "跳过设置",
"getStarted": "开始使用"
}
}

View File

@@ -0,0 +1,69 @@
{
"title": "技能",
"subtitle": "浏览和管理 AI 能力",
"refresh": "刷新",
"openFolder": "打开技能文件夹",
"gatewayWarning": "网关未运行。没有活跃的网关,无法加载技能。",
"tabs": {
"installed": "已安装",
"marketplace": "市场"
},
"filter": {
"all": "全部 ({{count}})",
"builtIn": "内置 ({{count}})",
"marketplace": "市场 ({{count}})"
},
"search": "搜索技能...",
"searchMarketplace": "搜索市场...",
"searchButton": "搜索",
"noSkills": "未找到技能",
"noSkillsSearch": "尝试不同的搜索词",
"noSkillsAvailable": "暂无可用技能",
"detail": {
"info": "信息",
"config": "配置",
"description": "描述",
"version": "版本",
"author": "作者",
"source": "来源",
"coreSystem": "核心系统",
"bundled": "内置",
"userInstalled": "用户安装",
"enabled": "已启用",
"disabled": "已禁用",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "输入 API 密钥(可选)",
"apiKeyDesc": "此技能的主要 API 密钥。如果不需要或在别处配置,请留空。",
"envVars": "环境变量",
"addVariable": "添加变量",
"noEnvVars": "未配置环境变量。",
"keyPlaceholder": "键名 (例如 BASE_URL)",
"valuePlaceholder": "值",
"envNote": "注意:键名为空的行将在保存时自动移除。",
"saving": "保存中...",
"saveConfig": "保存配置",
"configSaved": "配置已保存",
"openManual": "打开手册",
"configurable": "可配置"
},
"toast": {
"enabled": "技能已启用",
"disabled": "技能已禁用",
"installed": "技能已安装并启用",
"uninstalled": "技能已成功卸载",
"openedEditor": "已在编辑器中打开",
"failedEditor": "无法打开编辑器",
"failedSave": "保存配置失败",
"failedOpenFolder": "无法打开技能文件夹",
"failedInstall": "安装失败",
"failedUninstall": "卸载失败"
},
"marketplace": {
"title": "市场",
"securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。",
"searching": "正在搜索 ClawHub...",
"noResults": "未找到匹配的技能。",
"emptyPrompt": "搜索新技能以扩展您的能力。",
"searchError": "ClawHub 搜索失败。请检查您的连接或安装。"
}
}

View File

@@ -44,8 +44,10 @@ import {
type ChannelConfigField,
} from '@/types/channel';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
export function Channels() {
const { t } = useTranslation('channels');
const { channels, loading, error, fetchChannels, deleteChannel } = useChannelsStore();
const gatewayStatus = useGatewayStore((state) => state.status);
@@ -110,19 +112,19 @@ export function Channels() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Channels</h1>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
Connect and manage your messaging channels
{t('subtitle')}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
{t('refresh')}
</Button>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Channel
{t('addChannel')}
</Button>
</div>
</div>
@@ -137,7 +139,7 @@ export function Channels() {
</div>
<div>
<p className="text-2xl font-bold">{channels.length}</p>
<p className="text-sm text-muted-foreground">Total Channels</p>
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
</div>
</div>
</CardContent>
@@ -150,7 +152,7 @@ export function Channels() {
</div>
<div>
<p className="text-2xl font-bold">{connectedCount}</p>
<p className="text-sm text-muted-foreground">Connected</p>
<p className="text-sm text-muted-foreground">{t('stats.connected')}</p>
</div>
</div>
</CardContent>
@@ -163,7 +165,7 @@ export function Channels() {
</div>
<div>
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
<p className="text-sm text-muted-foreground">Disconnected</p>
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
</div>
</div>
</CardContent>
@@ -176,7 +178,7 @@ export function Channels() {
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-500" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Channels cannot connect without an active Gateway.
{t('gatewayWarning')}
</span>
</CardContent>
</Card>
@@ -195,8 +197,8 @@ export function Channels() {
{channels.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Configured Channels</CardTitle>
<CardDescription>Channels you have set up</CardDescription>
<CardTitle>{t('configured')}</CardTitle>
<CardDescription>{t('configuredDesc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -205,10 +207,8 @@ export function Channels() {
key={channel.id}
channel={channel}
onDelete={() => {
if (confirm('Are you sure you want to delete this channel?')) {
deleteChannel(channel.id).then(() => {
fetchConfiguredTypes();
});
if (confirm(t('deleteConfirm'))) {
deleteChannel(channel.id);
}
}}
/>
@@ -223,9 +223,9 @@ export function Channels() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Available Channels</CardTitle>
<CardTitle>{t('available')}</CardTitle>
<CardDescription>
Click on a channel type to configure it
{t('availableDesc')}
</CardDescription>
</div>
<Button
@@ -233,7 +233,7 @@ export function Channels() {
size="sm"
onClick={() => setShowAllChannels(!showAllChannels)}
>
{showAllChannels ? 'Show Less' : 'Show All'}
{showAllChannels ? t('showLess') : t('showAll')}
</Button>
</div>
</CardHeader>
@@ -258,12 +258,12 @@ export function Channels() {
</p>
{isConfigured && (
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
Configured
{t('configuredBadge')}
</Badge>
)}
{!isConfigured && meta.isPlugin && (
<Badge variant="secondary" className="absolute top-2 right-2 text-xs">
Plugin
{t('pluginBadge')}
</Badge>
)}
</button>
@@ -349,6 +349,7 @@ interface AddChannelDialogProps {
}
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
const { t } = useTranslation('channels');
const { addChannel } = useChannelsStore();
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
@@ -422,7 +423,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
const onSuccess = async (...args: unknown[]) => {
const data = args[0] as { accountId?: string } | undefined;
toast.success('WhatsApp connected successfully!');
toast.success(t('toast.whatsappConnected'));
const accountId = data?.accountId || channelName.trim() || 'default';
try {
const saveResult = await window.electron.ipcRenderer.invoke(
@@ -452,7 +453,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
const onError = (...args: unknown[]) => {
const err = args[0] as string;
console.error('WhatsApp Login Error:', err);
toast.error(`WhatsApp Login Failed: ${err}`);
toast.error(t('toast.whatsappFailed', { error: err }));
setQrCode(null);
setConnecting(false);
};
@@ -468,7 +469,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// Cancel when unmounting or switching types
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
};
}, [selectedType, addChannel, channelName, onChannelAdded]);
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
const handleValidate = async () => {
if (!selectedType) return;
@@ -587,41 +588,42 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
token: configValues[meta.configFields[0]?.key] || undefined,
});
toast.success(`${meta.name} channel saved. Restarting Gateway to connect...`);
toast.success(t('toast.channelSaved', { name: meta.name }));
// Step 4: Restart the Gateway so it picks up the new channel config
// The Gateway watches the config file, but a restart ensures a clean start
// especially when adding a channel for the first time.
try {
await window.electron.ipcRenderer.invoke('gateway:restart');
toast.success(`${meta.name} channel is now connecting via Gateway`);
toast.success(t('toast.channelConnecting', { name: meta.name }));
} catch (restartError) {
console.warn('Gateway restart after channel config:', restartError);
toast.info('Config saved. Please restart the Gateway manually for the channel to connect.');
toast.info(t('toast.restartManual'));
}
// Brief delay so user can see the success state before dialog closes
await new Promise((resolve) => setTimeout(resolve, 800));
onChannelAdded();
} catch (error) {
toast.error(`Failed to configure channel: ${error}`);
toast.error(t('toast.configFailed', { error }));
setConnecting(false);
}
};
const openDocs = () => {
if (meta?.docsUrl) {
const url = t(meta.docsUrl);
try {
if (window.electron?.openExternal) {
window.electron.openExternal(meta.docsUrl);
window.electron.openExternal(url);
} else {
// Fallback: open in new window
window.open(meta.docsUrl, '_blank');
window.open(url, '_blank');
}
} catch (error) {
console.error('Failed to open docs:', error);
// Fallback: open in new window
window.open(meta.docsUrl, '_blank');
window.open(url, '_blank');
}
}
};
@@ -652,14 +654,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
<CardTitle>
{selectedType
? isExistingConfig
? `Update ${CHANNEL_NAMES[selectedType]}`
: `Configure ${CHANNEL_NAMES[selectedType]}`
: 'Add Channel'}
? t('dialog.updateTitle', { name: CHANNEL_NAMES[selectedType] })
: t('dialog.configureTitle', { name: CHANNEL_NAMES[selectedType] })
: t('dialog.addTitle')}
</CardTitle>
<CardDescription>
{selectedType && isExistingConfig
? 'Existing configuration loaded. You can update and re-save.'
: meta?.description || 'Select a messaging channel to connect'}
? t('dialog.existingDesc')
: meta ? t(meta.description) : t('dialog.selectDesc')}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
@@ -681,7 +683,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
<span className="text-3xl">{channelMeta.icon}</span>
<p className="font-medium mt-2">{channelMeta.name}</p>
<p className="text-xs text-muted-foreground mt-1">
{channelMeta.connectionType === 'qr' ? 'QR Code' : 'Token'}
{channelMeta.connectionType === 'qr' ? t('dialog.qrCode') : t('dialog.token')}
</p>
</button>
);
@@ -700,14 +702,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
)}
</div>
<p className="text-sm text-muted-foreground">
Scan this QR code with {meta?.name} to connect
{t('dialog.scanQR', { name: meta?.name })}
</p>
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={() => {
setQrCode(null);
handleConnect(); // Retry
}}>
Refresh Code
{t('dialog.refreshCode')}
</Button>
</div>
</div>
@@ -715,7 +717,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
// Loading saved config
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading configuration...</span>
<span className="ml-2 text-sm text-muted-foreground">{t('dialog.loadingConfig')}</span>
</div>
) : (
// Connection form
@@ -724,37 +726,37 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
{isExistingConfig && (
<div className="bg-blue-500/10 text-blue-600 dark:text-blue-400 p-3 rounded-lg text-sm flex items-center gap-2">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>Previously saved configuration has been loaded. Modify if needed and save.</span>
<span>{t('dialog.existingHint')}</span>
</div>
)}
{/* Instructions */}
<div className="bg-muted p-4 rounded-lg space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">How to connect:</p>
<p className="font-medium text-sm">{t('dialog.howToConnect')}</p>
<Button
variant="link"
className="p-0 h-auto text-sm"
onClick={openDocs}
>
<BookOpen className="h-3 w-3 mr-1" />
View docs
{t('dialog.viewDocs')}
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
</div>
<ol className="list-decimal list-inside text-sm text-muted-foreground space-y-1">
{meta?.instructions.map((instruction, i) => (
<li key={i}>{instruction}</li>
<li key={i}>{t(instruction)}</li>
))}
</ol>
</div>
{/* Channel name */}
<div className="space-y-2">
<Label htmlFor="name">Channel Name (optional)</Label>
<Label htmlFor="name">{t('dialog.channelName')}</Label>
<Input
id="name"
placeholder={`My ${meta?.name}`}
placeholder={t('dialog.channelNamePlaceholder', { name: meta?.name })}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
/>
@@ -784,7 +786,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
)}
<div className="min-w-0">
<h4 className="font-medium mb-1">
{validationResult.valid ? 'Credentials Verified' : 'Validation Failed'}
{validationResult.valid ? t('dialog.credentialsVerified') : t('dialog.validationFailed')}
</h4>
{validationResult.errors.length > 0 && (
<ul className="list-disc list-inside space-y-0.5">
@@ -802,7 +804,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
)}
{!validationResult.valid && validationResult.warnings.length > 0 && (
<div className="mt-2 text-yellow-600 dark:text-yellow-500">
<p className="font-medium text-xs uppercase mb-1">Warnings:</p>
<p className="font-medium text-xs uppercase mb-1">{t('dialog.warnings')}</p>
<ul className="list-disc list-inside space-y-0.5">
{validationResult.warnings.map((warn, i) => (
<li key={i}>{warn}</li>
@@ -819,7 +821,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
<div className="flex justify-between">
<Button variant="outline" onClick={() => onSelectType(null)}>
Back
{t('dialog.back')}
</Button>
<div className="flex gap-2">
{/* Validation Button - Only for token-based channels for now */}
@@ -832,12 +834,12 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
{validating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validating...
{t('dialog.validating')}
</>
) : (
<>
<ShieldCheck className="h-4 w-4 mr-2" />
Validate Config
{t('dialog.validateConfig')}
</>
)}
</Button>
@@ -849,14 +851,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
{connecting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{meta?.connectionType === 'qr' ? 'Generating QR...' : 'Validating & Saving...'}
{meta?.connectionType === 'qr' ? t('dialog.generatingQR') : t('dialog.validatingAndSaving')}
</>
) : meta?.connectionType === 'qr' ? (
'Generate QR Code'
t('dialog.generateQRCode')
) : (
<>
<Check className="h-4 w-4 mr-2" />
{isExistingConfig ? 'Update & Reconnect' : 'Save & Connect'}
{isExistingConfig ? t('dialog.updateAndReconnect') : t('dialog.saveAndConnect')}
</>
)}
</Button>
@@ -866,7 +868,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
)}
</CardContent>
</Card>
</div>
</div >
);
}
@@ -881,19 +883,20 @@ interface ConfigFieldProps {
}
function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) {
const { t } = useTranslation('channels');
const isPassword = field.type === 'password';
return (
<div className="space-y-2">
<Label htmlFor={field.key}>
{field.label}
{t(field.label)}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
<div className="flex gap-2">
<Input
id={field.key}
type={isPassword && !showSecret ? 'password' : 'text'}
placeholder={field.placeholder}
placeholder={field.placeholder ? t(field.placeholder) : undefined}
value={value}
onChange={(e) => onChange(e.target.value)}
className="font-mono text-sm"
@@ -911,12 +914,12 @@ function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: Con
</div>
{field.description && (
<p className="text-xs text-muted-foreground">
{field.description}
{t(field.description)}
</p>
)}
{field.envVar && (
<p className="text-xs text-muted-foreground">
Or set via environment variable: <code className="bg-muted px-1 rounded">{field.envVar}</code>
{t('dialog.envVar', { var: field.envVar })}
</p>
)}
</div>

View File

@@ -14,8 +14,10 @@ import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ChatToolbar } from './ChatToolbar';
import { extractText } from './message-utils';
import { useTranslation } from 'react-i18next';
export function Chat() {
const { t } = useTranslation('chat');
const gatewayStatus = useGatewayStore((s) => s.status);
const isGatewayRunning = gatewayStatus.state === 'running';
@@ -62,10 +64,9 @@ export function Chat() {
return (
<div className="flex h-[calc(100vh-8rem)] flex-col items-center justify-center text-center p-8">
<AlertCircle className="h-12 w-12 text-yellow-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">Gateway Not Running</h2>
<h2 className="text-xl font-semibold mb-2">{t('gatewayNotRunning')}</h2>
<p className="text-muted-foreground max-w-md">
The OpenClaw Gateway needs to be running to use chat.
It will start automatically, or you can start it from Settings.
{t('gatewayRequired')}
</p>
</div>
);
@@ -141,7 +142,7 @@ export function Chat() {
onClick={clearError}
className="text-xs text-destructive/60 hover:text-destructive underline"
>
Dismiss
{t('common:actions.dismiss')}
</button>
</div>
</div>
@@ -161,20 +162,21 @@ export function Chat() {
// ── Welcome Screen ──────────────────────────────────────────────
function WelcomeScreen() {
const { t } = useTranslation('chat');
return (
<div className="flex flex-col items-center justify-center text-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-6">
<Bot className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-bold mb-2">ClawX Chat</h2>
<h2 className="text-2xl font-bold mb-2">{t('welcome.title')}</h2>
<p className="text-muted-foreground mb-8 max-w-md">
Your AI assistant is ready. Start a conversation below.
{t('welcome.subtitle')}
</p>
<div className="grid grid-cols-2 gap-4 max-w-lg w-full">
{[
{ icon: MessageSquare, title: 'Ask Questions', desc: 'Get answers on any topic' },
{ icon: Sparkles, title: 'Creative Tasks', desc: 'Writing, brainstorming, ideas' },
{ icon: MessageSquare, title: t('welcome.askQuestions'), desc: t('welcome.askQuestionsDesc') },
{ icon: Sparkles, title: t('welcome.creativeTasks'), desc: t('welcome.creativeTasksDesc') },
].map((item, i) => (
<Card key={i} className="text-left">
<CardContent className="p-4">

View File

@@ -36,6 +36,7 @@ import { formatRelativeTime, cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
import { CHANNEL_ICONS } from '@/types/channel';
import { useTranslation } from 'react-i18next';
// Common cron schedule presets
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
@@ -121,6 +122,7 @@ interface TaskDialogProps {
}
function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const { t } = useTranslation('cron');
const { channels } = useChannelsStore();
const [saving, setSaving] = useState(false);
@@ -148,26 +150,26 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('Please enter a task name');
toast.error(t('toast.nameRequired'));
return;
}
if (!message.trim()) {
toast.error('Please enter a message');
toast.error(t('toast.messageRequired'));
return;
}
if (!channelId) {
toast.error('Please select a channel');
toast.error(t('toast.channelRequired'));
return;
}
// Validate Discord channel ID when Discord is selected
if (selectedChannel?.type === 'discord' && !discordChannelId.trim()) {
toast.error('Please enter a Discord Channel ID');
toast.error(t('toast.discordIdRequired'));
return;
}
const finalSchedule = useCustom ? customSchedule : schedule;
if (!finalSchedule.trim()) {
toast.error('Please select or enter a schedule');
toast.error(t('toast.scheduleRequired'));
return;
}
@@ -178,19 +180,23 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
? discordChannelId.trim()
: '';
await onSave({
name: name.trim(),
message: message.trim(),
schedule: finalSchedule,
target: {
channelType: selectedChannel!.type,
channelId: actualChannelId,
channelName: selectedChannel!.name,
},
enabled,
});
await onSave(
// ... (args omitted from replacement content, ensuring they match target if not changed, but here I am replacing the block)
// Wait, I should not replace the whole onSave call if I don't need to.
// Let's target the toast.
{
name: name.trim(),
message: message.trim(),
schedule: finalSchedule,
target: {
channelType: selectedChannel!.type,
channelId: actualChannelId,
channelName: selectedChannel!.name,
},
enabled,
});
onClose();
toast.success(job ? 'Task updated' : 'Task created');
toast.success(job ? t('toast.updated') : t('toast.created'));
} catch (err) {
toast.error(String(err));
} finally {
@@ -203,8 +209,8 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>{job ? 'Edit Task' : 'Create Task'}</CardTitle>
<CardDescription>Schedule an automated AI task</CardDescription>
<CardTitle>{job ? t('dialog.editTitle') : t('dialog.createTitle')}</CardTitle>
<CardDescription>{t('dialog.description')}</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
@@ -213,10 +219,10 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Task Name</Label>
<Label htmlFor="name">{t('dialog.taskName')}</Label>
<Input
id="name"
placeholder="e.g., Morning briefing"
placeholder={t('dialog.taskNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
@@ -224,10 +230,10 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
{/* Message */}
<div className="space-y-2">
<Label htmlFor="message">Message / Prompt</Label>
<Label htmlFor="message">{t('dialog.message')}</Label>
<Textarea
id="message"
placeholder="What should the AI do? e.g., Give me a summary of today's news and weather"
placeholder={t('dialog.messagePlaceholder')}
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
@@ -236,7 +242,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
{/* Schedule */}
<div className="space-y-2">
<Label>Schedule</Label>
<Label>{t('dialog.schedule')}</Label>
{!useCustom ? (
<div className="grid grid-cols-2 gap-2">
{schedulePresets.map((preset) => (
@@ -249,13 +255,21 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
className="justify-start"
>
<Timer className="h-4 w-4 mr-2" />
{preset.label}
{preset.label === 'Every minute' ? t('presets.everyMinute') :
preset.label === 'Every 5 minutes' ? t('presets.every5Min') :
preset.label === 'Every 15 minutes' ? t('presets.every15Min') :
preset.label === 'Every hour' ? t('presets.everyHour') :
preset.label === 'Daily at 9am' ? t('presets.daily9am') :
preset.label === 'Daily at 6pm' ? t('presets.daily6pm') :
preset.label === 'Weekly (Mon 9am)' ? t('presets.weeklyMon') :
preset.label === 'Monthly (1st at 9am)' ? t('presets.monthly1st') :
preset.label}
</Button>
))}
</div>
) : (
<Input
placeholder="Cron expression (e.g., 0 9 * * *)"
placeholder={t('dialog.cronPlaceholder')}
value={customSchedule}
onChange={(e) => setCustomSchedule(e.target.value)}
/>
@@ -267,16 +281,16 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
onClick={() => setUseCustom(!useCustom)}
className="text-xs"
>
{useCustom ? 'Use presets' : 'Use custom cron'}
{useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')}
</Button>
</div>
{/* Target Channel */}
<div className="space-y-2">
<Label>Target Channel</Label>
<Label>{t('dialog.targetChannel')}</Label>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channels available. Add a channel first.
{t('dialog.noChannels')}
</p>
) : (
<div className="grid grid-cols-2 gap-2">
@@ -300,23 +314,23 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
{/* Discord Channel ID - only shown when Discord is selected */}
{isDiscord && (
<div className="space-y-2">
<Label>Discord Channel ID</Label>
<Label>{t('dialog.discordChannelId')}</Label>
<Input
value={discordChannelId}
onChange={(e) => setDiscordChannelId(e.target.value)}
placeholder="e.g., 1438452657525100686"
placeholder={t('dialog.discordChannelIdPlaceholder')}
/>
<p className="text-xs text-muted-foreground">
Right-click the Discord channel Copy Channel ID
{t('dialog.discordChannelIdDesc')}
</p>
</div>
)}
{/* Enabled */}
<div className="flex items-center justify-between">
<div>
<Label>Enable immediately</Label>
<Label>{t('dialog.enableImmediately')}</Label>
<p className="text-sm text-muted-foreground">
Start running this task after creation
{t('dialog.enableImmediatelyDesc')}
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
@@ -336,7 +350,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{job ? 'Save Changes' : 'Create Task'}
{job ? t('dialog.saveChanges') : t('dialog.createTitle')}
</>
)}
</Button>
@@ -357,13 +371,14 @@ interface CronJobCardProps {
}
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
const { t } = useTranslation('cron');
const [triggering, setTriggering] = useState(false);
const handleTrigger = async () => {
setTriggering(true);
try {
await onTrigger();
toast.success('Task triggered successfully');
toast.success(t('toast.triggered'));
} catch (error) {
console.error('Failed to trigger cron job:', error);
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
@@ -373,7 +388,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
};
const handleDelete = () => {
if (confirm('Are you sure you want to delete this task?')) {
if (confirm(t('card.deleteConfirm'))) {
onDelete();
}
};
@@ -407,7 +422,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
</div>
<div className="flex items-center gap-2">
<Badge variant={job.enabled ? 'success' : 'secondary'}>
{job.enabled ? 'Active' : 'Paused'}
{job.enabled ? t('stats.active') : t('stats.paused')}
</Badge>
<Switch
checked={job.enabled}
@@ -435,7 +450,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
{job.lastRun && (
<span className="flex items-center gap-1">
<History className="h-4 w-4" />
Last: {formatRelativeTime(job.lastRun.time)}
{t('card.last')}: {formatRelativeTime(job.lastRun.time)}
{job.lastRun.success ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
@@ -447,7 +462,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
{job.nextRun && job.enabled && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Next: {new Date(job.nextRun).toLocaleString()}
{t('card.next')}: {new Date(job.nextRun).toLocaleString()}
</span>
)}
</div>
@@ -473,7 +488,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
) : (
<Play className="h-4 w-4" />
)}
<span className="ml-1">Run Now</span>
<span className="ml-1">{t('card.runNow')}</span>
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4" />
@@ -490,6 +505,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
}
export function Cron() {
const { t } = useTranslation('cron');
const { jobs, loading, error, fetchJobs, createJob, updateJob, toggleJob, deleteJob, triggerJob } = useCronStore();
const { fetchChannels } = useChannelsStore();
const gatewayStatus = useGatewayStore((state) => state.status);
@@ -522,20 +538,20 @@ export function Cron() {
const handleToggle = useCallback(async (id: string, enabled: boolean) => {
try {
await toggleJob(id, enabled);
toast.success(enabled ? 'Task enabled' : 'Task paused');
toast.success(enabled ? t('toast.enabled') : t('toast.paused'));
} catch {
toast.error('Failed to update task');
toast.error(t('toast.failedUpdate'));
}
}, [toggleJob]);
}, [toggleJob, t]);
const handleDelete = useCallback(async (id: string) => {
try {
await deleteJob(id);
toast.success('Task deleted');
toast.success(t('toast.deleted'));
} catch {
toast.error('Failed to delete task');
toast.error(t('toast.failedDelete'));
}
}, [deleteJob]);
}, [deleteJob, t]);
if (loading) {
return (
@@ -550,15 +566,15 @@ export function Cron() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Scheduled Tasks</h1>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
Automate AI workflows with scheduled tasks
{t('subtitle')}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchJobs} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
{t('refresh')}
</Button>
<Button
onClick={() => {
@@ -568,7 +584,7 @@ export function Cron() {
disabled={!isGatewayRunning}
>
<Plus className="h-4 w-4 mr-2" />
New Task
{t('newTask')}
</Button>
</div>
</div>
@@ -579,7 +595,7 @@ export function Cron() {
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Scheduled tasks cannot be managed without an active Gateway.
{t('gatewayWarning')}
</span>
</CardContent>
</Card>
@@ -595,7 +611,7 @@ export function Cron() {
</div>
<div>
<p className="text-2xl font-bold">{jobs.length}</p>
<p className="text-sm text-muted-foreground">Total Tasks</p>
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
</div>
</div>
</CardContent>
@@ -608,7 +624,7 @@ export function Cron() {
</div>
<div>
<p className="text-2xl font-bold">{activeJobs.length}</p>
<p className="text-sm text-muted-foreground">Active</p>
<p className="text-sm text-muted-foreground">{t('stats.active')}</p>
</div>
</div>
</CardContent>
@@ -621,7 +637,7 @@ export function Cron() {
</div>
<div>
<p className="text-2xl font-bold">{pausedJobs.length}</p>
<p className="text-sm text-muted-foreground">Paused</p>
<p className="text-sm text-muted-foreground">{t('stats.paused')}</p>
</div>
</div>
</CardContent>
@@ -634,7 +650,7 @@ export function Cron() {
</div>
<div>
<p className="text-2xl font-bold">{failedJobs.length}</p>
<p className="text-sm text-muted-foreground">Failed</p>
<p className="text-sm text-muted-foreground">{t('stats.failed')}</p>
</div>
</div>
</CardContent>
@@ -656,10 +672,9 @@ export function Cron() {
<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>
<h3 className="text-lg font-medium mb-2">{t('empty.title')}</h3>
<p className="text-muted-foreground text-center mb-4 max-w-md">
Create scheduled tasks to automate AI workflows.
Tasks can send messages, run queries, or perform actions at specified times.
{t('empty.description')}
</p>
<Button
onClick={() => {
@@ -669,7 +684,7 @@ export function Cron() {
disabled={!isGatewayRunning}
>
<Plus className="h-4 w-4 mr-2" />
Create Your First Task
{t('empty.create')}
</Button>
</CardContent>
</Card>

View File

@@ -22,8 +22,10 @@ import { useChannelsStore } from '@/stores/channels';
import { useSkillsStore } from '@/stores/skills';
import { useSettingsStore } from '@/stores/settings';
import { StatusBadge } from '@/components/common/StatusBadge';
import { useTranslation } from 'react-i18next';
export function Dashboard() {
const { t } = useTranslation('dashboard');
const gatewayStatus = useGatewayStore((state) => state.status);
const { channels, fetchChannels } = useChannelsStore();
const { skills, fetchSkills } = useSkillsStore();
@@ -87,7 +89,7 @@ export function Dashboard() {
{/* Gateway Status */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Gateway</CardTitle>
<CardTitle className="text-sm font-medium">{t('gateway')}</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@@ -96,7 +98,7 @@ export function Dashboard() {
</div>
{gatewayStatus.state === 'running' && (
<p className="mt-1 text-xs text-muted-foreground">
Port: {gatewayStatus.port} | PID: {gatewayStatus.pid || 'N/A'}
{t('port', { port: gatewayStatus.port })} | {t('pid', { pid: gatewayStatus.pid || 'N/A' })}
</p>
)}
</CardContent>
@@ -105,13 +107,13 @@ export function Dashboard() {
{/* Channels */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Channels</CardTitle>
<CardTitle className="text-sm font-medium">{t('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
{t('connectedOf', { connected: connectedChannels, total: channels.length })}
</p>
</CardContent>
</Card>
@@ -119,13 +121,13 @@ export function Dashboard() {
{/* Skills */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Skills</CardTitle>
<CardTitle className="text-sm font-medium">{t('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
{t('enabledOf', { enabled: enabledSkills, total: skills.length })}
</p>
</CardContent>
</Card>
@@ -133,7 +135,7 @@ export function Dashboard() {
{/* Uptime */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
<CardTitle className="text-sm font-medium">{t('uptime')}</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
@@ -141,7 +143,7 @@ export function Dashboard() {
{uptime > 0 ? formatUptime(uptime) : '—'}
</div>
<p className="text-xs text-muted-foreground">
{gatewayStatus.state === 'running' ? 'Since last restart' : 'Gateway not running'}
{gatewayStatus.state === 'running' ? t('sinceRestart') : t('gatewayNotRunning')}
</p>
</CardContent>
</Card>
@@ -150,33 +152,33 @@ export function Dashboard() {
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks and shortcuts</CardDescription>
<CardTitle>{t('quickActions.title')}</CardTitle>
<CardDescription>{t('quickActions.description')}</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>
<span>{t('quickActions.addChannel')}</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>
<span>{t('quickActions.browseSkills')}</span>
</Link>
</Button>
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
<Link to="/">
<MessageSquare className="h-5 w-5" />
<span>Open Chat</span>
<span>{t('quickActions.openChat')}</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>
<span>{t('quickActions.settings')}</span>
</Link>
</Button>
{devModeUnlocked && (
@@ -186,7 +188,7 @@ export function Dashboard() {
onClick={openDevConsole}
>
<Terminal className="h-5 w-5" />
<span>Dev Console</span>
<span>{t('quickActions.devConsole')}</span>
</Button>
)}
</div>
@@ -198,15 +200,15 @@ export function Dashboard() {
{/* Connected Channels */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Connected Channels</CardTitle>
<CardTitle className="text-lg">{t('connectedChannels')}</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>
<p>{t('noChannels')}</p>
<Button variant="link" asChild className="mt-2">
<Link to="/channels">Add your first channel</Link>
<Link to="/channels">{t('addFirst')}</Link>
</Button>
</div>
) : (
@@ -240,15 +242,15 @@ export function Dashboard() {
{/* Enabled Skills */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Active Skills</CardTitle>
<CardTitle className="text-lg">{t('activeSkills')}</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>
<p>{t('noSkills')}</p>
<Button variant="link" asChild className="mt-2">
<Link to="/skills">Enable some skills</Link>
<Link to="/skills">{t('enableSome')}</Link>
</Button>
</div>
) : (
@@ -264,7 +266,7 @@ export function Dashboard() {
))}
{skills.filter((s) => s.enabled).length > 12 && (
<Badge variant="outline">
+{skills.filter((s) => s.enabled).length - 12} more
{t('more', { count: skills.filter((s) => s.enabled).length - 12 })}
</Badge>
)}
</div>

View File

@@ -28,6 +28,8 @@ import { useGatewayStore } from '@/stores/gateway';
import { useUpdateStore } from '@/stores/update';
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
import { UpdateSettings } from '@/components/settings/UpdateSettings';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n';
type ControlUiInfo = {
url: string;
token: string;
@@ -35,9 +37,12 @@ type ControlUiInfo = {
};
export function Settings() {
const { t } = useTranslation('settings');
const {
theme,
setTheme,
language,
setLanguage,
gatewayAutoStart,
setGatewayAutoStart,
autoCheckUpdate,
@@ -47,7 +52,7 @@ export function Settings() {
devModeUnlocked,
setDevModeUnlocked,
} = useSettingsStore();
const { status: gatewayStatus, restart: restartGateway } = useGatewayStore();
const currentVersion = useUpdateStore((state) => state.currentVersion);
const updateSetAutoDownload = useUpdateStore((state) => state.setAutoDownload);
@@ -85,7 +90,7 @@ export function Settings() {
// ignore
}
};
// Open developer console
const openDevConsole = async () => {
try {
@@ -127,7 +132,7 @@ export function Settings() {
if (!controlUiInfo?.token) return;
try {
await navigator.clipboard.writeText(controlUiInfo.token);
toast.success('Gateway token copied');
toast.success(t('developer.tokenCopied'));
} catch (error) {
toast.error(`Failed to copy token: ${String(error)}`);
}
@@ -169,7 +174,7 @@ export function Settings() {
if (!openclawCliCommand) return;
try {
await navigator.clipboard.writeText(openclawCliCommand);
toast.success('CLI command copied');
toast.success(t('developer.cmdCopied'));
} catch (error) {
toast.error(`Failed to copy command: ${String(error)}`);
}
@@ -180,9 +185,9 @@ export function Settings() {
try {
const confirmation = await window.electron.ipcRenderer.invoke('dialog:message', {
type: 'question',
title: 'Install OpenClaw Command',
message: 'Install the "openclaw" command?',
detail: 'This will create ~/.local/bin/openclaw. Ensure ~/.local/bin is on your PATH if you want to run it globally.',
title: t('developer.installTitle'),
message: t('developer.installMessage'),
detail: t('developer.installDetail'),
buttons: ['Cancel', 'Install'],
defaultId: 1,
cancelId: 0,
@@ -212,21 +217,21 @@ export function Settings() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
Configure your ClawX experience
{t('subtitle')}
</p>
</div>
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel</CardDescription>
<CardTitle>{t('appearance.title')}</CardTitle>
<CardDescription>{t('appearance.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Theme</Label>
<Label>{t('appearance.theme')}</Label>
<div className="flex gap-2">
<Button
variant={theme === 'light' ? 'default' : 'outline'}
@@ -234,7 +239,7 @@ export function Settings() {
onClick={() => setTheme('light')}
>
<Sun className="h-4 w-4 mr-2" />
Light
{t('appearance.light')}
</Button>
<Button
variant={theme === 'dark' ? 'default' : 'outline'}
@@ -242,7 +247,7 @@ export function Settings() {
onClick={() => setTheme('dark')}
>
<Moon className="h-4 w-4 mr-2" />
Dark
{t('appearance.dark')}
</Button>
<Button
variant={theme === 'system' ? 'default' : 'outline'}
@@ -250,39 +255,54 @@ export function Settings() {
onClick={() => setTheme('system')}
>
<Monitor className="h-4 w-4 mr-2" />
System
{t('appearance.system')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('appearance.language')}</Label>
<div className="flex gap-2">
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={language === lang.code ? 'default' : 'outline'}
size="sm"
onClick={() => setLanguage(lang.code)}
>
{lang.label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
{/* AI Providers */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
AI Providers
{t('aiProviders.title')}
</CardTitle>
<CardDescription>Configure your AI model providers and API keys</CardDescription>
<CardDescription>{t('aiProviders.description')}</CardDescription>
</CardHeader>
<CardContent>
<ProvidersSettings />
</CardContent>
</Card>
{/* Gateway */}
<Card>
<CardHeader>
<CardTitle>Gateway</CardTitle>
<CardDescription>OpenClaw Gateway settings</CardDescription>
<CardTitle>{t('gateway.title')}</CardTitle>
<CardDescription>{t('gateway.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Status</Label>
<Label>{t('gateway.status')}</Label>
<p className="text-sm text-muted-foreground">
Port: {gatewayStatus.port}
{t('gateway.port')}: {gatewayStatus.port}
</p>
</div>
<div className="flex items-center gap-2">
@@ -291,50 +311,50 @@ export function Settings() {
gatewayStatus.state === 'running'
? 'success'
: gatewayStatus.state === 'error'
? 'destructive'
: 'secondary'
? 'destructive'
: 'secondary'
}
>
{gatewayStatus.state}
</Badge>
<Button variant="outline" size="sm" onClick={restartGateway}>
<RefreshCw className="h-4 w-4 mr-2" />
Restart
{t('common:actions.restart')}
</Button>
<Button variant="outline" size="sm" onClick={handleShowLogs}>
<FileText className="h-4 w-4 mr-2" />
Logs
{t('gateway.logs')}
</Button>
</div>
</div>
{showLogs && (
<div className="mt-4 p-4 rounded-lg bg-black/10 dark:bg-black/40 border border-border">
<div className="flex items-center justify-between mb-2">
<p className="font-medium text-sm">Application Logs</p>
<p className="font-medium text-sm">{t('gateway.appLogs')}</p>
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleOpenLogDir}>
<ExternalLink className="h-3 w-3 mr-1" />
Open Folder
{t('gateway.openFolder')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setShowLogs(false)}>
Close
{t('common:actions.close')}
</Button>
</div>
</div>
<pre className="text-xs text-muted-foreground bg-background/50 p-3 rounded max-h-60 overflow-auto whitespace-pre-wrap font-mono">
{logContent || '(No logs available yet)'}
{logContent || t('chat:noLogs')}
</pre>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Auto-start Gateway</Label>
<Label>{t('gateway.autoStart')}</Label>
<p className="text-sm text-muted-foreground">
Start Gateway when ClawX launches
{t('gateway.autoStartDesc')}
</p>
</div>
<Switch
@@ -344,26 +364,26 @@ export function Settings() {
</div>
</CardContent>
</Card>
{/* Updates */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Updates
{t('updates.title')}
</CardTitle>
<CardDescription>Keep ClawX up to date</CardDescription>
<CardDescription>{t('updates.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UpdateSettings />
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Auto-check for updates</Label>
<Label>{t('updates.autoCheck')}</Label>
<p className="text-sm text-muted-foreground">
Check for updates on startup
{t('updates.autoCheckDesc')}
</p>
</div>
<Switch
@@ -371,12 +391,12 @@ export function Settings() {
onCheckedChange={setAutoCheckUpdate}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Auto-download updates</Label>
<Label>{t('updates.autoDownload')}</Label>
<p className="text-sm text-muted-foreground">
Download updates in the background
{t('updates.autoDownloadDesc')}
</p>
</div>
<Switch
@@ -393,15 +413,15 @@ export function Settings() {
{/* Advanced */}
<Card>
<CardHeader>
<CardTitle>Advanced</CardTitle>
<CardDescription>Power-user options</CardDescription>
<CardTitle>{t('advanced.title')}</CardTitle>
<CardDescription>{t('advanced.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Developer Mode</Label>
<Label>{t('advanced.devMode')}</Label>
<p className="text-sm text-muted-foreground">
Show developer tools and shortcuts
{t('advanced.devModeDesc')}
</p>
</div>
<Switch
@@ -411,139 +431,139 @@ export function Settings() {
</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 the Control UI with gateway token injected
</p>
<div className="space-y-2 pt-2">
<Label>Gateway Token</Label>
<CardTitle>{t('developer.title')}</CardTitle>
<CardDescription>{t('developer.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('developer.console')}</Label>
<p className="text-sm text-muted-foreground">
Paste this into Control UI settings if prompted
{t('developer.consoleDesc')}
</p>
<div className="flex gap-2">
<Input
readOnly
value={controlUiInfo?.token || ''}
placeholder="Token unavailable"
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={refreshControlUiInfo}
disabled={!devModeUnlocked}
>
<RefreshCw className="h-4 w-4 mr-2" />
Load
</Button>
<Button
type="button"
variant="outline"
onClick={handleCopyGatewayToken}
disabled={!controlUiInfo?.token}
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
</div>
</div>
</div>
{showCliTools && (
<>
<Separator />
<div className="space-y-2">
<Label>OpenClaw CLI</Label>
<Button variant="outline" onClick={openDevConsole}>
<Terminal className="h-4 w-4 mr-2" />
{t('developer.openConsole')}
<ExternalLink className="h-3 w-3 ml-2" />
</Button>
<p className="text-xs text-muted-foreground">
{t('developer.consoleNote')}
</p>
<div className="space-y-2 pt-2">
<Label>{t('developer.gatewayToken')}</Label>
<p className="text-sm text-muted-foreground">
Copy a command to run OpenClaw without modifying PATH.
{t('developer.gatewayTokenDesc')}
</p>
{isWindows && (
<p className="text-xs text-muted-foreground">
PowerShell command.
</p>
)}
<div className="flex gap-2">
<Input
readOnly
value={openclawCliCommand}
placeholder={openclawCliError || 'Command unavailable'}
value={controlUiInfo?.token || ''}
placeholder={t('developer.tokenUnavailable')}
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={handleCopyCliCommand}
disabled={!openclawCliCommand}
onClick={refreshControlUiInfo}
disabled={!devModeUnlocked}
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('common:actions.load')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCopyGatewayToken}
disabled={!controlUiInfo?.token}
>
<Copy className="h-4 w-4 mr-2" />
Copy
{t('common:actions.copy')}
</Button>
</div>
{isMac && !isDev && (
<div className="space-y-1">
</div>
</div>
{showCliTools && (
<>
<Separator />
<div className="space-y-2">
<Label>{t('developer.cli')}</Label>
<p className="text-sm text-muted-foreground">
{t('developer.cliDesc')}
</p>
{isWindows && (
<p className="text-xs text-muted-foreground">
{t('developer.cliPowershell')}
</p>
)}
<div className="flex gap-2">
<Input
readOnly
value={openclawCliCommand}
placeholder={openclawCliError || t('developer.cmdUnavailable')}
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={handleInstallCliCommand}
disabled={installingCli}
onClick={handleCopyCliCommand}
disabled={!openclawCliCommand}
>
<Terminal className="h-4 w-4 mr-2" />
Install "openclaw" Command
<Copy className="h-4 w-4 mr-2" />
{t('common:actions.copy')}
</Button>
<p className="text-xs text-muted-foreground">
Installs ~/.local/bin/openclaw (no admin required)
</p>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
{isMac && !isDev && (
<div className="space-y-1">
<Button
type="button"
variant="outline"
onClick={handleInstallCliCommand}
disabled={installingCli}
>
<Terminal className="h-4 w-4 mr-2" />
{t('developer.installCmd')}
</Button>
<p className="text-xs text-muted-foreground">
{t('developer.installCmdDesc')}
</p>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
)}
{/* About */}
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
<CardTitle>{t('about.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>ClawX</strong> - Graphical AI Assistant
<strong>{t('about.appName')}</strong> - {t('about.tagline')}
</p>
<p>Based on OpenClaw</p>
<p>Version {currentVersion}</p>
<p>{t('about.basedOn')}</p>
<p>{t('about.version', { version: currentVersion })}</p>
<div className="flex gap-4 pt-2">
<Button
variant="link"
className="h-auto p-0"
onClick={() => window.electron.openExternal('https://clawx.dev')}
>
Documentation
{t('about.docs')}
</Button>
<Button
variant="link"
className="h-auto p-0"
onClick={() => window.electron.openExternal('https://github.com/ValueCell-ai/ClawX')}
>
GitHub
{t('about.github')}
</Button>
</div>
</CardContent>

View File

@@ -5,12 +5,12 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Loader2,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
Eye,
EyeOff,
@@ -26,6 +26,8 @@ import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway';
import { useSettingsStore } from '@/stores/settings';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from '@/i18n';
import { toast } from 'sonner';
import {
CHANNEL_META,
@@ -107,9 +109,10 @@ const providers = SETUP_PROVIDERS;
// NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup
export function Setup() {
const { t } = useTranslation(['setup', 'channels']);
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<number>(STEP.WELCOME);
// Setup state
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [providerConfigured, setProviderConfigured] = useState(false);
@@ -118,16 +121,16 @@ export function Setup() {
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
// Runtime check status
const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false);
const safeStepIndex = Number.isInteger(currentStep)
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
: STEP.WELCOME;
const step = steps[safeStepIndex] ?? steps[STEP.WELCOME];
const isFirstStep = safeStepIndex === STEP.WELCOME;
const isLastStep = safeStepIndex === steps.length - 1;
const markSetupComplete = useSettingsStore((state) => state.markSetupComplete);
// Derive canProceed based on current step - computed directly to avoid useEffect
const canProceed = useMemo(() => {
switch (safeStepIndex) {
@@ -147,27 +150,27 @@ export function Setup() {
return true;
}
}, [safeStepIndex, providerConfigured, runtimeChecksPassed]);
const handleNext = async () => {
if (isLastStep) {
// Complete setup
markSetupComplete();
toast.success('Setup complete! Welcome to ClawX');
toast.success(t('complete.title'));
navigate('/');
} else {
setCurrentStep((i) => i + 1);
}
};
const handleBack = () => {
setCurrentStep((i) => Math.max(i - 1, 0));
};
const handleSkip = () => {
markSetupComplete();
navigate('/');
};
// Auto-proceed when installation is complete
const handleInstallationComplete = useCallback((skills: string[]) => {
setInstalledSkills(skills);
@@ -176,7 +179,8 @@ export function Setup() {
setCurrentStep((i) => i + 1);
}, 1000);
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white">
{/* Progress Indicator */}
@@ -190,8 +194,8 @@ export function Setup() {
i < safeStepIndex
? 'border-primary bg-primary text-primary-foreground'
: i === safeStepIndex
? 'border-primary text-primary'
: 'border-slate-600 text-slate-600'
? 'border-primary text-primary'
: 'border-slate-600 text-slate-600'
)}
>
{i < safeStepIndex ? (
@@ -212,7 +216,7 @@ export function Setup() {
))}
</div>
</div>
{/* Step Content */}
<AnimatePresence mode="wait">
<motion.div
@@ -223,15 +227,15 @@ export function Setup() {
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>
<h1 className="text-3xl font-bold mb-2">{t(`steps.${step.id}.title`)}</h1>
<p className="text-slate-400">{t(`steps.${step.id}.description`)}</p>
</div>
{/* Step-specific content */}
<div className="rounded-xl bg-white/10 backdrop-blur p-8 mb-8">
{safeStepIndex === STEP.WELCOME && <WelcomeContent />}
{safeStepIndex === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
{safeStepIndex === STEP.PROVIDER && (
{safeStepIndex === STEP.WELCOME && <WelcomeContent />}
{safeStepIndex === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
{safeStepIndex === STEP.PROVIDER && (
<ProviderContent
providers={providers}
selectedProvider={selectedProvider}
@@ -241,22 +245,22 @@ export function Setup() {
onConfiguredChange={setProviderConfigured}
/>
)}
{safeStepIndex === STEP.CHANNEL && <SetupChannelContent />}
{safeStepIndex === STEP.INSTALLING && (
{safeStepIndex === STEP.CHANNEL && <SetupChannelContent />}
{safeStepIndex === STEP.INSTALLING && (
<InstallingContent
skills={defaultSkills}
onComplete={handleInstallationComplete}
onSkip={() => setCurrentStep((i) => i + 1)}
/>
)}
{safeStepIndex === STEP.COMPLETE && (
{safeStepIndex === STEP.COMPLETE && (
<CompleteContent
selectedProvider={selectedProvider}
installedSkills={installedSkills}
/>
)}
</div>
{/* Navigation - hidden during installation step */}
{safeStepIndex !== STEP.INSTALLING && (
<div className="flex justify-between">
@@ -264,27 +268,27 @@ export function Setup() {
{!isFirstStep && (
<Button variant="ghost" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
{t('nav.back')}
</Button>
)}
</div>
<div className="flex gap-2">
{safeStepIndex === STEP.CHANNEL && (
<Button variant="ghost" onClick={handleNext}>
Skip this step
{t('nav.skipStep')}
</Button>
)}
{!isLastStep && safeStepIndex !== STEP.RUNTIME && safeStepIndex !== STEP.CHANNEL && (
<Button variant="ghost" onClick={handleSkip}>
Skip Setup
{t('nav.skipSetup')}
</Button>
)}
<Button onClick={handleNext} disabled={!canProceed}>
{isLastStep ? (
'Get Started'
t('nav.getStarted')
) : (
<>
Next
{t('nav.next')}
<ChevronRight className="h-4 w-4 ml-2" />
</>
)}
@@ -301,30 +305,48 @@ export function Setup() {
// ==================== Step Content Components ====================
function WelcomeContent() {
const { t } = useTranslation(['setup', 'settings']);
const { language, setLanguage } = useSettingsStore();
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>
<h2 className="text-xl font-semibold">{t('welcome.title')}</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.
{t('welcome.description')}
</p>
<ul className="text-left space-y-2 text-slate-300">
{/* Language Selector */}
<div className="flex justify-center gap-2 py-2">
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={language === lang.code ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setLanguage(lang.code)}
className="h-7 text-xs"
>
{lang.label}
</Button>
))}
</div>
<ul className="text-left space-y-2 text-slate-300 pt-2">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Zero command-line required
{t('welcome.features.noCommand')}
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Modern, beautiful interface
{t('welcome.features.modernUI')}
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Pre-installed skill bundles
{t('welcome.features.bundles')}
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-400" />
Cross-platform support
{t('welcome.features.crossPlatform')}
</li>
</ul>
</div>
@@ -336,9 +358,10 @@ interface RuntimeContentProps {
}
function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const { t } = useTranslation('setup');
const gatewayStatus = useGatewayStore((state) => state.status);
const startGateway = useGatewayStore((state) => state.start);
const [checks, setChecks] = useState({
nodejs: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
openclaw: { status: 'checking' as 'checking' | 'success' | 'error', message: '' },
@@ -348,7 +371,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
const [logContent, setLogContent] = useState('');
const [openclawDir, setOpenclawDir] = useState('');
const gatewayTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const runChecks = useCallback(async () => {
// Reset checks
setChecks({
@@ -356,13 +379,13 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
openclaw: { status: 'checking', message: '' },
gateway: { status: 'checking', message: '' },
});
// Check Node.js — always available in Electron
setChecks((prev) => ({
...prev,
nodejs: { status: 'success', message: 'Node.js is available (Electron built-in)' },
nodejs: { status: 'success', message: t('runtime.status.success') },
}));
// Check OpenClaw package status
try {
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as {
@@ -371,32 +394,32 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
dir: string;
version?: string;
};
setOpenclawDir(openclawStatus.dir);
if (!openclawStatus.packageExists) {
setChecks((prev) => ({
...prev,
openclaw: {
status: 'error',
message: `OpenClaw package not found at: ${openclawStatus.dir}`
openclaw: {
status: 'error',
message: `OpenClaw package not found at: ${openclawStatus.dir}`
},
}));
} else if (!openclawStatus.isBuilt) {
setChecks((prev) => ({
...prev,
openclaw: {
status: 'error',
message: 'OpenClaw package found but dist is missing'
openclaw: {
status: 'error',
message: 'OpenClaw package found but dist is missing'
},
}));
} else {
const versionLabel = openclawStatus.version ? ` v${openclawStatus.version}` : '';
setChecks((prev) => ({
...prev,
openclaw: {
status: 'success',
message: `OpenClaw package ready${versionLabel}`
openclaw: {
status: 'success',
message: `OpenClaw package ready${versionLabel}`
},
}));
}
@@ -406,7 +429,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
openclaw: { status: 'error', message: `Check failed: ${error}` },
}));
}
// Check Gateway — read directly from store to avoid stale closure
// Don't immediately report error; gateway may still be initializing
const currentGateway = useGatewayStore.getState().status;
@@ -418,39 +441,39 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
} else if (currentGateway.state === 'error') {
setChecks((prev) => ({
...prev,
gateway: { status: 'error', message: currentGateway.error || 'Failed to start' },
gateway: { status: 'error', message: currentGateway.error || t('runtime.status.error') },
}));
} else {
// Gateway is 'stopped', 'starting', or 'reconnecting'
// Keep as 'checking' — the dedicated useEffect will update when status changes
setChecks((prev) => ({
...prev,
gateway: {
status: 'checking',
message: currentGateway.state === 'starting' ? 'Starting...' : 'Waiting for gateway...'
gateway: {
status: 'checking',
message: currentGateway.state === 'starting' ? t('runtime.status.checking') : 'Waiting for gateway...'
},
}));
}
}, []);
}, [t]);
useEffect(() => {
runChecks();
}, [runChecks]);
// Update canProceed when gateway status changes
useEffect(() => {
const allPassed = checks.nodejs.status === 'success'
&& checks.openclaw.status === 'success'
const allPassed = checks.nodejs.status === 'success'
&& checks.openclaw.status === 'success'
&& (checks.gateway.status === 'success' || gatewayStatus.state === 'running');
onStatusChange(allPassed);
}, [checks, gatewayStatus, onStatusChange]);
// Update gateway check when gateway status changes
useEffect(() => {
if (gatewayStatus.state === 'running') {
setChecks((prev) => ({
...prev,
gateway: { status: 'success', message: `Running on port ${gatewayStatus.port}` },
gateway: { status: 'success', message: t('runtime.status.gatewayRunning', { port: gatewayStatus.port }) },
}));
} else if (gatewayStatus.state === 'error') {
setChecks((prev) => ({
@@ -464,20 +487,20 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
}));
}
// 'stopped' state: keep current check status (likely 'checking') to allow startup time
}, [gatewayStatus]);
}, [gatewayStatus, t]);
// Gateway startup timeout — show error only after giving enough time to initialize
useEffect(() => {
if (gatewayTimeoutRef.current) {
clearTimeout(gatewayTimeoutRef.current);
gatewayTimeoutRef.current = null;
}
// If gateway is already in a terminal state, no timeout needed
if (gatewayStatus.state === 'running' || gatewayStatus.state === 'error') {
return;
}
// Set timeout for non-terminal states (stopped, starting, reconnecting)
gatewayTimeoutRef.current = setTimeout(() => {
setChecks((prev) => {
@@ -490,7 +513,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
return prev;
});
}, 120 * 1000); // 120 seconds — enough for gateway to fully initialize
return () => {
if (gatewayTimeoutRef.current) {
clearTimeout(gatewayTimeoutRef.current);
@@ -498,7 +521,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
}
};
}, [gatewayStatus.state]);
const handleStartGateway = async () => {
setChecks((prev) => ({
...prev,
@@ -506,7 +529,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
}));
await startGateway();
};
const handleShowLogs = async () => {
try {
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
@@ -528,7 +551,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
// ignore
}
};
const renderStatus = (status: 'checking' | 'success' | 'error', message: string) => {
if (status === 'checking') {
return (
@@ -553,29 +576,29 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
</span>
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Checking Environment</h2>
<h2 className="text-xl font-semibold">{t('runtime.title')}</h2>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleShowLogs}>
View Logs
{t('runtime.viewLogs')}
</Button>
<Button variant="ghost" size="sm" onClick={runChecks}>
<RefreshCw className="h-4 w-4 mr-2" />
Re-check
{t('runtime.recheck')}
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Node.js Runtime</span>
<span>{t('runtime.nodejs')}</span>
{renderStatus(checks.nodejs.status, checks.nodejs.message)}
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<div>
<span>OpenClaw Package</span>
<span>{t('runtime.openclaw')}</span>
{openclawDir && (
<p className="text-xs text-slate-500 mt-0.5 font-mono truncate max-w-[300px]">
{openclawDir}
@@ -596,21 +619,21 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
{renderStatus(checks.gateway.status, checks.gateway.message)}
</div>
</div>
{(checks.nodejs.status === 'error' || checks.openclaw.status === 'error') && (
<div className="mt-4 p-4 rounded-lg bg-red-900/20 border border-red-500/20">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-400 mt-0.5" />
<div>
<p className="font-medium text-red-400">Environment issue detected</p>
<p className="font-medium text-red-400">{t('runtime.issue.title')}</p>
<p className="text-sm text-slate-300 mt-1">
Please ensure OpenClaw is properly installed. Check the logs for details.
{t('runtime.issue.desc')}
</p>
</div>
</div>
</div>
)}
{/* Log viewer panel */}
{showLogs && (
<div className="mt-4 p-4 rounded-lg bg-black/40 border border-slate-600">
@@ -644,14 +667,15 @@ interface ProviderContentProps {
onConfiguredChange: (configured: boolean) => void;
}
function ProviderContent({
providers,
selectedProvider,
onSelectProvider,
apiKey,
function ProviderContent({
providers,
selectedProvider,
onSelectProvider,
apiKey,
onApiKeyChange,
onConfiguredChange,
}: ProviderContentProps) {
const { t } = useTranslation('setup');
const [showKey, setShowKey] = useState(false);
const [validating, setValidating] = useState(false);
const [keyValid, setKeyValid] = useState<boolean | null>(null);
@@ -732,18 +756,18 @@ function ProviderContent({
})();
return () => { cancelled = true; };
}, [onApiKeyChange, selectedProvider, providers]);
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
const showModelIdField = selectedProviderData?.showModelId ?? false;
const requiresKey = selectedProviderData?.requiresApiKey ?? false;
const handleValidateAndSave = async () => {
if (!selectedProvider) return;
setValidating(true);
setKeyValid(null);
try {
// Validate key if the provider requires one and a key was entered
if (requiresKey && apiKey) {
@@ -753,11 +777,11 @@ function ProviderContent({
apiKey,
{ baseUrl: baseUrl.trim() || undefined }
) as { valid: boolean; error?: string };
setKeyValid(result.valid);
if (!result.valid) {
toast.error(result.error || 'Invalid API key');
toast.error(result.error || t('provider.invalid'));
setValidating(false);
return;
}
@@ -773,8 +797,8 @@ function ProviderContent({
const providerIdForSave =
selectedProvider === 'custom'
? (selectedProviderConfigId?.startsWith('custom-')
? selectedProviderConfigId
: `custom-${crypto.randomUUID()}`)
? selectedProviderConfigId
: `custom-${crypto.randomUUID()}`)
: selectedProvider;
// Save provider config + API key, then set as default
@@ -808,7 +832,7 @@ function ProviderContent({
setSelectedProviderConfigId(providerIdForSave);
onConfiguredChange(true);
toast.success('Provider configured');
toast.success(t('provider.valid'));
} catch (error) {
setKeyValid(false);
onConfiguredChange(false);
@@ -823,12 +847,12 @@ function ProviderContent({
selectedProvider
&& (requiresKey ? apiKey.length > 0 : true)
&& (showModelIdField ? modelId.trim().length > 0 : true);
return (
<div className="space-y-6">
{/* Provider selector — dropdown */}
<div className="space-y-2">
<Label htmlFor="provider">Model Provider</Label>
<Label htmlFor="provider">{t('provider.label')}</Label>
<div className="relative">
<select
id="provider"
@@ -847,7 +871,7 @@ function ProviderContent({
'focus:outline-none focus:ring-2 focus:ring-ring',
)}
>
<option value="" disabled className="bg-slate-800 text-slate-400">Select a provider...</option>
<option value="" disabled className="bg-slate-800 text-slate-400">{t('provider.selectPlaceholder')}</option>
{providers.map((p) => (
<option key={p.id} value={p.id} className="bg-slate-800 text-white">
{p.icon} {p.name}{p.model ? `${p.model}` : ''}
@@ -869,7 +893,7 @@ function ProviderContent({
{/* Base URL field (for siliconflow, ollama, custom) */}
{showBaseUrlField && (
<div className="space-y-2">
<Label htmlFor="baseUrl">Base URL</Label>
<Label htmlFor="baseUrl">{t('provider.baseUrl')}</Label>
<Input
id="baseUrl"
type="text"
@@ -888,7 +912,7 @@ function ProviderContent({
{/* Model ID field (for siliconflow etc.) */}
{showModelIdField && (
<div className="space-y-2">
<Label htmlFor="modelId">Model ID</Label>
<Label htmlFor="modelId">{t('provider.modelId')}</Label>
<Input
id="modelId"
type="text"
@@ -902,7 +926,7 @@ function ProviderContent({
className="bg-white/5 border-white/10"
/>
<p className="text-xs text-slate-500">
The model identifier from your provider (e.g. deepseek-ai/DeepSeek-V3)
{t('provider.modelIdDesc')}
</p>
</div>
)}
@@ -910,7 +934,7 @@ function ProviderContent({
{/* API Key field (hidden for ollama) */}
{requiresKey && (
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Label htmlFor="apiKey">{t('provider.apiKey')}</Label>
<div className="relative">
<Input
id="apiKey"
@@ -945,17 +969,17 @@ function ProviderContent({
{validating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
{requiresKey ? 'Validate & Save' : 'Save'}
{requiresKey ? t('provider.validateSave') : t('provider.save')}
</Button>
{keyValid !== null && (
<p className={cn('text-sm text-center', keyValid ? 'text-green-400' : 'text-red-400')}>
{keyValid ? '✓ Provider configured successfully' : '✗ Invalid API key'}
{keyValid ? `${t('provider.valid')}` : `${t('provider.invalid')}`}
</p>
)}
<p className="text-sm text-slate-400 text-center">
Your API key is stored locally on your machine.
{t('provider.storedLocally')}
</p>
</motion.div>
)}
@@ -966,6 +990,7 @@ function ProviderContent({
// ==================== Setup Channel Content ====================
function SetupChannelContent() {
const { t } = useTranslation(['setup', 'channels']);
const [selectedChannel, setSelectedChannel] = useState<ChannelType | null>(null);
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
@@ -1046,10 +1071,10 @@ function SetupChannelContent() {
<div className="text-center space-y-4">
<div className="text-5xl"></div>
<h2 className="text-xl font-semibold">
{meta?.name || 'Channel'} Connected
{t('channel.connected', { name: meta?.name || 'Channel' })}
</h2>
<p className="text-slate-300">
Your channel has been configured. It will connect when the Gateway starts.
{t('channel.connectedDesc')}
</p>
<Button
variant="ghost"
@@ -1060,7 +1085,7 @@ function SetupChannelContent() {
setConfigValues({});
}}
>
Configure another channel
{t('channel.configureAnother')}
</Button>
</div>
);
@@ -1072,9 +1097,9 @@ function SetupChannelContent() {
<div className="space-y-4">
<div className="text-center mb-2">
<div className="text-4xl mb-3">📡</div>
<h2 className="text-xl font-semibold">Connect a Messaging Channel</h2>
<h2 className="text-xl font-semibold">{t('channel.title')}</h2>
<p className="text-slate-300 text-sm mt-1">
Choose a platform to connect your AI assistant to. You can add more channels later in Settings.
{t('channel.subtitle')}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -1090,7 +1115,7 @@ function SetupChannelContent() {
<span className="text-3xl">{channelMeta.icon}</span>
<p className="font-medium mt-2">{channelMeta.name}</p>
<p className="text-xs text-slate-400 mt-1 line-clamp-2">
{channelMeta.description}
{t(channelMeta.description)}
</p>
</button>
);
@@ -1112,16 +1137,16 @@ function SetupChannelContent() {
</button>
<div>
<h2 className="text-xl font-semibold flex items-center gap-2">
<span>{meta?.icon}</span> Configure {meta?.name}
<span>{meta?.icon}</span> {t('channel.configure', { name: meta?.name })}
</h2>
<p className="text-slate-400 text-sm">{meta?.description}</p>
<p className="text-slate-400 text-sm mt-1">{t(meta?.description || '')}</p>
</div>
</div>
{/* Instructions */}
<div className="p-3 rounded-lg bg-white/5 text-sm">
<div className="flex items-center justify-between mb-2">
<p className="font-medium text-slate-200">How to connect:</p>
<p className="font-medium text-slate-200">{t('channel.howTo')}</p>
{meta?.docsUrl && (
<button
onClick={() => {
@@ -1134,14 +1159,14 @@ function SetupChannelContent() {
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
<BookOpen className="h-3 w-3" />
View docs
{t('channel.viewDocs')}
<ExternalLink className="h-3 w-3" />
</button>
)}
</div>
<ol className="list-decimal list-inside text-slate-400 space-y-1">
{meta?.instructions.map((inst, i) => (
<li key={i}>{inst}</li>
<li key={i}>{t(inst)}</li>
))}
</ol>
</div>
@@ -1152,14 +1177,14 @@ function SetupChannelContent() {
return (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`setup-${field.key}`} className="text-slate-200">
{field.label}
{t(field.label)}
{field.required && <span className="text-red-400 ml-1">*</span>}
</Label>
<div className="flex gap-2">
<Input
id={`setup-${field.key}`}
type={isPassword && !showSecrets[field.key] ? 'password' : 'text'}
placeholder={field.placeholder}
placeholder={field.placeholder ? t(field.placeholder) : undefined}
value={configValues[field.key] || ''}
onChange={(e) => setConfigValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
autoComplete="off"
@@ -1178,7 +1203,7 @@ function SetupChannelContent() {
)}
</div>
{field.description && (
<p className="text-xs text-slate-500">{field.description}</p>
<p className="text-xs text-slate-500 mt-1">{t(field.description)}</p>
)}
</div>
);
@@ -1201,12 +1226,12 @@ function SetupChannelContent() {
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validating & Saving...
{t('provider.validateSave')}
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Validate & Save
{t('provider.validateSave')}
</>
)}
</Button>
@@ -1233,18 +1258,19 @@ interface InstallingContentProps {
}
function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProps) {
const { t } = useTranslation('setup');
const [skillStates, setSkillStates] = useState<SkillInstallState[]>(
skills.map((s) => ({ ...s, status: 'pending' as InstallStatus }))
);
const [overallProgress, setOverallProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const installStarted = useRef(false);
// Real installation process
useEffect(() => {
if (installStarted.current) return;
installStarted.current = true;
const runRealInstall = async () => {
try {
// Step 1: Initialize all skills to 'installing' state for UI
@@ -1252,15 +1278,15 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
setOverallProgress(10);
// Step 2: Call the backend to install uv and setup Python
const result = await window.electron.ipcRenderer.invoke('uv:install-all') as {
success: boolean;
error?: string
const result = await window.electron.ipcRenderer.invoke('uv:install-all') as {
success: boolean;
error?: string
};
if (result.success) {
setSkillStates(prev => prev.map(s => ({ ...s, status: 'completed' })));
setOverallProgress(100);
await new Promise((resolve) => setTimeout(resolve, 800));
onComplete(skills.map(s => s.id));
} else {
@@ -1274,7 +1300,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
toast.error('Installation error');
}
};
runRealInstall();
}, [skills, onComplete]);
@@ -1290,34 +1316,34 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
return <XCircle className="h-5 w-5 text-red-400" />;
}
};
const getStatusText = (skill: SkillInstallState) => {
switch (skill.status) {
case 'pending':
return <span className="text-slate-500">Pending</span>;
return <span className="text-slate-500">{t('installing.status.pending')}</span>;
case 'installing':
return <span className="text-primary">Installing...</span>;
return <span className="text-primary">{t('installing.status.installing')}</span>;
case 'completed':
return <span className="text-green-400">Installed</span>;
return <span className="text-green-400">{t('installing.status.installed')}</span>;
case 'failed':
return <span className="text-red-400">Failed</span>;
return <span className="text-red-400">{t('installing.status.failed')}</span>;
}
};
return (
<div className="space-y-6">
<div className="text-center">
<div className="text-4xl mb-4"></div>
<h2 className="text-xl font-semibold mb-2">Installing Essential Components</h2>
<h2 className="text-xl font-semibold mb-2">{t('installing.title')}</h2>
<p className="text-slate-300">
Setting up the tools needed to power your AI assistant
{t('installing.subtitle')}
</p>
</div>
{/* Progress bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-400">Progress</span>
<span className="text-slate-400">{t('installing.progress')}</span>
<span className="text-primary">{overallProgress}%</span>
</div>
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
@@ -1329,7 +1355,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
/>
</div>
</div>
{/* Skill list */}
<div className="space-y-2 max-h-48 overflow-y-auto">
{skillStates.map((skill) => (
@@ -1356,7 +1382,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
{/* Error Message Display */}
{errorMessage && (
<motion.div
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="p-4 rounded-lg bg-red-900/30 border border-red-500/50 text-red-200 text-sm"
@@ -1364,25 +1390,25 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold">Setup Error:</p>
<p className="font-semibold">{t('installing.error')}</p>
<pre className="text-xs bg-black/30 p-2 rounded overflow-x-auto whitespace-pre-wrap font-monospace">
{errorMessage}
</pre>
<Button
variant="link"
<Button
variant="link"
className="text-red-400 p-0 h-auto text-xs underline"
onClick={() => window.location.reload()}
>
Try restarting the app
{t('installing.restart')}
</Button>
</div>
</div>
</motion.div>
)}
{!errorMessage && (
<p className="text-sm text-slate-400 text-center">
This may take a few moments...
{t('installing.wait')}
</p>
)}
<div className="flex justify-end">
@@ -1391,7 +1417,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
className="text-slate-400"
onClick={onSkip}
>
Skip this step
{t('installing.skip')}
</Button>
</div>
</div>
@@ -1403,46 +1429,46 @@ interface CompleteContentProps {
}
function CompleteContent({ selectedProvider, installedSkills }: CompleteContentProps) {
const { t } = useTranslation('setup');
const gatewayStatus = useGatewayStore((state) => state.status);
const providerData = providers.find((p) => p.id === selectedProvider);
const installedSkillNames = defaultSkills
.filter((s) => installedSkills.includes(s.id))
.map((s) => s.name)
.join(', ');
return (
<div className="text-center space-y-6">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-xl font-semibold">Setup Complete!</h2>
<h2 className="text-xl font-semibold">{t('complete.title')}</h2>
<p className="text-slate-300">
ClawX is configured and ready to use. You can now start chatting with
your AI assistant.
{t('complete.subtitle')}
</p>
<div className="space-y-3 text-left max-w-md mx-auto">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>AI Provider</span>
<span>{t('complete.provider')}</span>
<span className="text-green-400">
{providerData ? `${providerData.icon} ${providerData.name}` : '—'}
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Components</span>
<span>{t('complete.components')}</span>
<span className="text-green-400">
{installedSkillNames || `${installedSkills.length} installed`}
{installedSkillNames || `${installedSkills.length} ${t('installing.status.installed')}`}
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-white/5">
<span>Gateway</span>
<span>{t('complete.gateway')}</span>
<span className={gatewayStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}>
{gatewayStatus.state === 'running' ? '✓ Running' : gatewayStatus.state}
{gatewayStatus.state === 'running' ? `${t('complete.running')}` : gatewayStatus.state}
</span>
</div>
</div>
<p className="text-sm text-slate-400">
You can customize skills and connect channels in Settings
{t('complete.footer')}
</p>
</div>
);

View File

@@ -40,6 +40,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { Skill, MarketplaceSkill } from '@/types/skill';
import { useTranslation } from 'react-i18next';
@@ -52,6 +53,7 @@ interface SkillDetailDialogProps {
}
function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) {
const { t } = useTranslation('skills');
const { fetchSkills } = useSkillsStore();
const [activeTab, setActiveTab] = useState('info');
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
@@ -91,12 +93,12 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
try {
const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string };
if (result.success) {
toast.success('Opened in editor');
toast.success(t('toast.openedEditor'));
} else {
toast.error(result.error || 'Failed to open editor');
toast.error(result.error || t('toast.failedEditor'));
}
} catch (err) {
toast.error('Failed to open editor: ' + String(err));
toast.error(t('toast.failedEditor') + ': ' + String(err));
}
}
};
@@ -148,9 +150,9 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
// Refresh skills from gateway to get updated config
await fetchSkills();
toast.success('Configuration saved');
toast.success(t('detail.configSaved'));
} catch (err) {
toast.error('Failed to save configuration: ' + String(err));
toast.error(t('toast.failedSave') + ': ' + String(err));
} finally {
setIsSaving(false);
}
@@ -176,7 +178,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={handleOpenEditor}>
<FileCode className="h-3 w-3" />
Open Manual
{t('detail.openManual')}
</Button>
</>
)}
@@ -191,8 +193,8 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="px-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Information</TabsTrigger>
<TabsTrigger value="config" disabled={skill.isCore}>Configuration</TabsTrigger>
<TabsTrigger value="info">{t('detail.info')}</TabsTrigger>
<TabsTrigger value="config" disabled={skill.isCore}>{t('detail.config')}</TabsTrigger>
</TabsList>
</div>
@@ -201,27 +203,27 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<TabsContent value="info" className="mt-0 space-y-4">
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.description')}</h3>
<p className="text-sm mt-1">{skill.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Version</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.version')}</h3>
<p className="font-mono text-sm">{skill.version}</p>
</div>
{skill.author && (
<div>
<h3 className="text-sm font-medium text-muted-foreground">Author</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.author')}</h3>
<p className="text-sm">{skill.author}</p>
</div>
)}
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Source</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.source')}</h3>
<Badge variant="secondary" className="mt-1 font-normal">
{skill.isCore ? 'Core System' : skill.isBundled ? 'Bundled' : 'User Installed'}
{skill.isCore ? t('detail.coreSystem') : skill.isBundled ? t('detail.bundled') : t('detail.userInstalled')}
</Badge>
</div>
</div>
@@ -236,14 +238,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
API Key
</h3>
<Input
placeholder="Enter API Key (optional)"
placeholder={t('detail.apiKeyPlaceholder')}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
type="password"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
The primary API key for this skill. Leave blank if not required or configured elsewhere.
{t('detail.apiKeyDesc')}
</p>
</div>
@@ -276,7 +278,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
}}
>
<Plus className="h-3 w-3" />
Add Variable
{t('detail.addVariable')}
</Button>
</div>
@@ -284,7 +286,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<div className="pt-4 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{envVars.length === 0 && (
<p className="text-xs text-muted-foreground italic h-8 flex items-center">
No environment variables configured.
{t('detail.noEnvVars')}
</p>
)}
@@ -294,14 +296,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
value={env.key}
onChange={(e) => handleUpdateEnv(index, 'key', e.target.value)}
className="flex-1 font-mono text-xs bg-muted/20"
placeholder="KEY (e.g. BASE_URL)"
placeholder={t('detail.keyPlaceholder')}
/>
<span className="text-muted-foreground ml-1 mr-1">=</span>
<Input
value={env.value}
onChange={(e) => handleUpdateEnv(index, 'value', e.target.value)}
className="flex-1 font-mono text-xs bg-muted/20"
placeholder="VALUE"
placeholder={t('detail.valuePlaceholder')}
/>
<Button
variant="ghost"
@@ -316,7 +318,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
{envVars.length > 0 && (
<p className="text-[10px] text-muted-foreground italic px-1 pt-1">
Note: Rows with empty keys will be automatically removed during save.
{t('detail.envNote')}
</p>
)}
</div>
@@ -327,7 +329,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<div className="pt-4 flex justify-end">
<Button onClick={handleSaveConfig} className="gap-2" disabled={isSaving}>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save Configuration'}
{isSaving ? t('detail.saving') : t('detail.saveConfig')}
</Button>
</div>
</TabsContent>
@@ -339,12 +341,12 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
{skill.enabled ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-green-600 dark:text-green-400">Enabled</span>
<span className="text-green-600 dark:text-green-400">{t('detail.enabled')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-muted-foreground">Disabled</span>
<span className="text-muted-foreground">{t('detail.disabled')}</span>
</>
)}
</div>
@@ -532,6 +534,7 @@ export function Skills() {
searchError,
installing
} = useSkillsStore();
const { t } = useTranslation('skills');
const gatewayStatus = useGatewayStore((state) => state.status);
const [searchQuery, setSearchQuery] = useState('');
const [marketplaceQuery, setMarketplaceQuery] = useState('');
@@ -602,15 +605,15 @@ export function Skills() {
try {
if (enable) {
await enableSkill(skillId);
toast.success('Skill enabled');
toast.success(t('toast.enabled'));
} else {
await disableSkill(skillId);
toast.success('Skill disabled');
toast.success(t('toast.disabled'));
}
} catch (err) {
toast.error(String(err));
}
}, [enableSkill, disableSkill]);
}, [enableSkill, disableSkill, t]);
const handleOpenSkillsFolder = useCallback(async () => {
try {
@@ -623,9 +626,9 @@ export function Skills() {
throw new Error(result);
}
} catch (err) {
toast.error('Failed to open skills folder: ' + String(err));
toast.error(t('toast.failedOpenFolder') + ': ' + String(err));
}
}, []);
}, [t]);
// Handle marketplace search
const handleMarketplaceSearch = useCallback((e: React.FormEvent) => {
@@ -642,11 +645,11 @@ export function Skills() {
// Automatically enable after install
// We need to find the skill id which is usually the slug
await enableSkill(slug);
toast.success('Skill installed and enabled');
toast.success(t('toast.installed'));
} catch (err) {
toast.error(`Failed to install: ${String(err)}`);
toast.error(t('toast.failedInstall') + ': ' + String(err));
}
}, [installSkill, enableSkill]);
}, [installSkill, enableSkill, t]);
// Initial marketplace load (Discovery)
useEffect(() => {
@@ -670,11 +673,11 @@ export function Skills() {
const handleUninstall = useCallback(async (slug: string) => {
try {
await uninstallSkill(slug);
toast.success('Skill uninstalled successfully');
toast.success(t('toast.uninstalled'));
} catch (err) {
toast.error(`Failed to uninstall: ${String(err)}`);
toast.error(t('toast.failedUninstall') + ': ' + String(err));
}
}, [uninstallSkill]);
}, [uninstallSkill, t]);
if (loading) {
return (
@@ -689,19 +692,19 @@ export function Skills() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Skills</h1>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
Browse and manage AI capabilities
{t('subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchSkills} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
{t('refresh')}
</Button>
<Button variant="outline" onClick={handleOpenSkillsFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Open Skills Folder
{t('openFolder')}
</Button>
</div>
</div>
@@ -712,7 +715,7 @@ export function Skills() {
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Skills cannot be loaded without an active Gateway.
{t('gatewayWarning')}
</span>
</CardContent>
</Card>
@@ -723,11 +726,11 @@ export function Skills() {
<TabsList>
<TabsTrigger value="all" className="gap-2">
<Puzzle className="h-4 w-4" />
Installed
{t('tabs.installed')}
</TabsTrigger>
<TabsTrigger value="marketplace" className="gap-2">
<Globe className="h-4 w-4" />
Marketplace
{t('tabs.marketplace')}
</TabsTrigger>
{/* <TabsTrigger value="bundles" className="gap-2">
<Package className="h-4 w-4" />
@@ -741,7 +744,7 @@ export function Skills() {
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search skills..."
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -763,7 +766,7 @@ export function Skills() {
className="gap-2"
>
<Puzzle className="h-3 w-3" />
Built-in ({sourceStats.builtIn})
{t('filter.builtIn', { count: sourceStats.builtIn })}
</Button>
<Button
variant={selectedSource === 'marketplace' ? 'default' : 'outline'}
@@ -772,7 +775,7 @@ export function Skills() {
className="gap-2"
>
<Globe className="h-3 w-3" />
Marketplace ({sourceStats.marketplace})
{t('filter.marketplace', { count: sourceStats.marketplace })}
</Button>
</div>
</div>
@@ -792,9 +795,9 @@ export function Skills() {
<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>
<h3 className="text-lg font-medium mb-2">{t('noSkills')}</h3>
<p className="text-muted-foreground">
{searchQuery ? 'Try a different search term' : 'No skills available'}
{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}
</p>
</CardContent>
</Card>
@@ -867,7 +870,7 @@ export function Skills() {
{skill.configurable && (
<Badge variant="secondary" className="text-xs">
<Settings className="h-3 w-3 mr-1" />
Configurable
{t('detail.configurable')}
</Badge>
)}
</div>
@@ -884,7 +887,7 @@ export function Skills() {
<CardContent className="py-4 flex items-start gap-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-muted-foreground">
Click skill card to view its documentation and security information on ClawHub before installation.
{t('marketplace.securityNote')}
</div>
</CardContent>
</Card>
@@ -893,7 +896,7 @@ export function Skills() {
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search marketplace..."
placeholder={t('searchMarketplace')}
value={marketplaceQuery}
onChange={(e) => setMarketplaceQuery(e.target.value)}
className="pl-9"
@@ -933,7 +936,7 @@ export function Skills() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Search
{t('searchButton')}
</motion.div>
)}
</AnimatePresence>
@@ -946,7 +949,7 @@ export function Skills() {
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="py-3 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>ClawHub search failed. Check your connection or installation.</span>
<span>{t('marketplace.searchError')}</span>
</CardContent>
</Card>
)}
@@ -971,13 +974,13 @@ export function Skills() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Marketplace</h3>
<h3 className="text-lg font-medium mb-2">{t('marketplace.title')}</h3>
<p className="text-muted-foreground text-center max-w-sm">
{searching
? 'Searching ClawHub...'
? t('marketplace.searching')
: marketplaceQuery
? 'No skills found matching your search.'
: 'Search for new skills to expand your capabilities.'}
? t('marketplace.noResults')
: t('marketplace.emptyPrompt')}
</p>
</CardContent>
</Card>

View File

@@ -4,6 +4,7 @@
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import i18n from '@/i18n';
type Theme = 'light' | 'dark' | 'system';
type UpdateChannel = 'stable' | 'beta' | 'dev';
@@ -14,23 +15,23 @@ interface SettingsState {
language: string;
startMinimized: boolean;
launchAtStartup: boolean;
// Gateway
gatewayAutoStart: boolean;
gatewayPort: number;
// Update
updateChannel: UpdateChannel;
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
// UI State
sidebarCollapsed: boolean;
devModeUnlocked: boolean;
// Setup
setupComplete: boolean;
// Actions
setTheme: (theme: Theme) => void;
setLanguage: (language: string) => void;
@@ -49,7 +50,12 @@ interface SettingsState {
const defaultSettings = {
theme: 'system' as Theme,
language: 'en',
language: (() => {
const lang = navigator.language.toLowerCase();
if (lang.startsWith('zh')) return 'zh';
if (lang.startsWith('ja')) return 'ja';
return 'en';
})(),
startMinimized: false,
launchAtStartup: false,
gatewayAutoStart: true,
@@ -66,9 +72,9 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
...defaultSettings,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); },
setStartMinimized: (startMinimized) => set({ startMinimized }),
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }),

View File

@@ -115,75 +115,75 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'telegram',
name: 'Telegram',
icon: '✈️',
description: 'Connect Telegram using a bot token from @BotFather',
description: 'channels:meta.telegram.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/telegram',
docsUrl: 'channels:meta.telegram.docsUrl',
configFields: [
{
key: 'botToken',
label: 'Bot Token',
label: 'channels:meta.telegram.fields.botToken.label',
type: 'password',
placeholder: '123456:ABC-DEF...',
placeholder: 'channels:meta.telegram.fields.botToken.placeholder',
required: true,
envVar: 'TELEGRAM_BOT_TOKEN',
},
{
key: 'allowedUsers',
label: 'Allowed User IDs',
label: 'channels:meta.telegram.fields.allowedUsers.label',
type: 'text',
placeholder: 'e.g. 123456789, 987654321',
description: 'Comma separated list of User IDs allowed to use the bot. Required for security.',
placeholder: 'channels:meta.telegram.fields.allowedUsers.placeholder',
description: 'channels:meta.telegram.fields.allowedUsers.description',
required: true,
},
],
instructions: [
'Open Telegram and search for @BotFather',
'Send /newbot and follow the instructions',
'Copy the bot token provided',
'Paste the token below',
'Get your User ID from @userinfobot and paste it below',
'channels:meta.telegram.instructions.0',
'channels:meta.telegram.instructions.1',
'channels:meta.telegram.instructions.2',
'channels:meta.telegram.instructions.3',
'channels:meta.telegram.instructions.4',
],
},
discord: {
id: 'discord',
name: 'Discord',
icon: '🎮',
description: 'Connect Discord using a bot token from Developer Portal',
description: 'channels:meta.discord.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/discord#how-to-create-your-own-bot',
docsUrl: 'channels:meta.discord.docsUrl',
configFields: [
{
key: 'token',
label: 'Bot Token',
label: 'channels:meta.discord.fields.token.label',
type: 'password',
placeholder: 'Your Discord bot token',
placeholder: 'channels:meta.discord.fields.token.placeholder',
required: true,
envVar: 'DISCORD_BOT_TOKEN',
},
{
key: 'guildId',
label: 'Guild/Server ID',
label: 'channels:meta.discord.fields.guildId.label',
type: 'text',
placeholder: 'e.g., 123456789012345678',
placeholder: 'channels:meta.discord.fields.guildId.placeholder',
required: true,
description: 'Limit bot to a specific server. Right-click server → Copy Server ID.',
description: 'channels:meta.discord.fields.guildId.description',
},
{
key: 'channelId',
label: 'Channel ID (optional)',
label: 'channels:meta.discord.fields.channelId.label',
type: 'text',
placeholder: 'e.g., 123456789012345678',
placeholder: 'channels:meta.discord.fields.channelId.placeholder',
required: false,
description: 'Limit bot to a specific channel. Right-click channel → Copy Channel ID.',
description: 'channels:meta.discord.fields.channelId.description',
},
],
instructions: [
'Go to Discord Developer Portal → Applications → New Application',
'In Bot section: Add Bot, then copy the Bot Token',
'Enable Message Content Intent + Server Members Intent in Bot → Privileged Gateway Intents',
'In OAuth2 → URL Generator: select "bot" + "applications.commands", add message permissions',
'Invite the bot to your server using the generated URL',
'Paste the bot token below',
'channels:meta.discord.instructions.0',
'channels:meta.discord.instructions.1',
'channels:meta.discord.instructions.2',
'channels:meta.discord.instructions.3',
'channels:meta.discord.instructions.4',
'channels:meta.discord.instructions.5',
],
},
@@ -191,69 +191,69 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'whatsapp',
name: 'WhatsApp',
icon: '📱',
description: 'Connect WhatsApp by scanning a QR code (no phone number required)',
description: 'channels:meta.whatsapp.description',
connectionType: 'qr',
docsUrl: 'https://docs.openclaw.ai/channels/whatsapp',
docsUrl: 'channels:meta.whatsapp.docsUrl',
configFields: [],
instructions: [
'Open WhatsApp on your phone',
'Go to Settings > Linked Devices > Link a Device',
'Scan the QR code shown below',
'The system will automatically identify your phone number',
'channels:meta.whatsapp.instructions.0',
'channels:meta.whatsapp.instructions.1',
'channels:meta.whatsapp.instructions.2',
'channels:meta.whatsapp.instructions.3',
],
},
signal: {
id: 'signal',
name: 'Signal',
icon: '🔒',
description: 'Connect Signal using signal-cli',
description: 'channels:meta.signal.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/signal',
docsUrl: 'channels:meta.signal.docsUrl',
configFields: [
{
key: 'phoneNumber',
label: 'Phone Number',
label: 'channels:meta.signal.fields.phoneNumber.label',
type: 'text',
placeholder: '+1234567890',
placeholder: 'channels:meta.signal.fields.phoneNumber.placeholder',
required: true,
},
],
instructions: [
'Install signal-cli on your system',
'Register or link your phone number',
'Enter your phone number below',
'channels:meta.signal.instructions.0',
'channels:meta.signal.instructions.1',
'channels:meta.signal.instructions.2',
],
},
feishu: {
id: 'feishu',
name: 'Feishu / Lark',
icon: '🐦',
description: 'Connect Feishu/Lark bot via WebSocket',
description: 'channels:meta.feishu.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/feishu#step-1-create-a-feishu-app',
docsUrl: 'channels:meta.feishu.docsUrl',
configFields: [
{
key: 'appId',
label: 'App ID',
label: 'channels:meta.feishu.fields.appId.label',
type: 'text',
placeholder: 'cli_xxxxxx',
placeholder: 'channels:meta.feishu.fields.appId.placeholder',
required: true,
envVar: 'FEISHU_APP_ID',
},
{
key: 'appSecret',
label: 'App Secret',
label: 'channels:meta.feishu.fields.appSecret.label',
type: 'password',
placeholder: 'Your app secret',
placeholder: 'channels:meta.feishu.fields.appSecret.placeholder',
required: true,
envVar: 'FEISHU_APP_SECRET',
},
],
instructions: [
'Go to Feishu Open Platform',
'Create a new application',
'Get App ID and App Secret',
'Configure event subscription',
'channels:meta.feishu.instructions.0',
'channels:meta.feishu.instructions.1',
'channels:meta.feishu.instructions.2',
'channels:meta.feishu.instructions.3',
],
isPlugin: true,
},
@@ -261,58 +261,58 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'imessage',
name: 'iMessage',
icon: '💬',
description: 'Connect iMessage via BlueBubbles (macOS)',
description: 'channels:meta.imessage.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/bluebubbles',
docsUrl: 'channels:meta.imessage.docsUrl',
configFields: [
{
key: 'serverUrl',
label: 'BlueBubbles Server URL',
label: 'channels:meta.imessage.fields.serverUrl.label',
type: 'text',
placeholder: 'http://localhost:1234',
placeholder: 'channels:meta.imessage.fields.serverUrl.placeholder',
required: true,
},
{
key: 'password',
label: 'Server Password',
label: 'channels:meta.imessage.fields.password.label',
type: 'password',
placeholder: 'Your server password',
placeholder: 'channels:meta.imessage.fields.password.placeholder',
required: true,
},
],
instructions: [
'Install BlueBubbles server on your Mac',
'Note the server URL and password',
'Enter the connection details below',
'channels:meta.imessage.instructions.0',
'channels:meta.imessage.instructions.1',
'channels:meta.imessage.instructions.2',
],
},
matrix: {
id: 'matrix',
name: 'Matrix',
icon: '🔗',
description: 'Connect to Matrix protocol',
description: 'channels:meta.matrix.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/matrix',
docsUrl: 'channels:meta.matrix.docsUrl',
configFields: [
{
key: 'homeserver',
label: 'Homeserver URL',
label: 'channels:meta.matrix.fields.homeserver.label',
type: 'text',
placeholder: 'https://matrix.org',
placeholder: 'channels:meta.matrix.fields.homeserver.placeholder',
required: true,
},
{
key: 'accessToken',
label: 'Access Token',
label: 'channels:meta.matrix.fields.accessToken.label',
type: 'password',
placeholder: 'Your access token',
placeholder: 'channels:meta.matrix.fields.accessToken.placeholder',
required: true,
},
],
instructions: [
'Create a Matrix account or use existing',
'Get an access token from your client',
'Enter the homeserver and token below',
'channels:meta.matrix.instructions.0',
'channels:meta.matrix.instructions.1',
'channels:meta.matrix.instructions.2',
],
isPlugin: true,
},
@@ -320,31 +320,31 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'line',
name: 'LINE',
icon: '🟢',
description: 'Connect LINE Messaging API',
description: 'channels:meta.line.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/line',
docsUrl: 'channels:meta.line.docsUrl',
configFields: [
{
key: 'channelAccessToken',
label: 'Channel Access Token',
label: 'channels:meta.line.fields.channelAccessToken.label',
type: 'password',
placeholder: 'Your LINE channel access token',
placeholder: 'channels:meta.line.fields.channelAccessToken.placeholder',
required: true,
envVar: 'LINE_CHANNEL_ACCESS_TOKEN',
},
{
key: 'channelSecret',
label: 'Channel Secret',
label: 'channels:meta.line.fields.channelSecret.label',
type: 'password',
placeholder: 'Your LINE channel secret',
placeholder: 'channels:meta.line.fields.channelSecret.placeholder',
required: true,
envVar: 'LINE_CHANNEL_SECRET',
},
],
instructions: [
'Go to LINE Developers Console',
'Create a Messaging API channel',
'Get Channel Access Token and Secret',
'channels:meta.line.instructions.0',
'channels:meta.line.instructions.1',
'channels:meta.line.instructions.2',
],
isPlugin: true,
},
@@ -352,32 +352,32 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'msteams',
name: 'Microsoft Teams',
icon: '👔',
description: 'Connect Microsoft Teams via Bot Framework',
description: 'channels:meta.msteams.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/msteams',
docsUrl: 'channels:meta.msteams.docsUrl',
configFields: [
{
key: 'appId',
label: 'App ID',
label: 'channels:meta.msteams.fields.appId.label',
type: 'text',
placeholder: 'Your Microsoft App ID',
placeholder: 'channels:meta.msteams.fields.appId.placeholder',
required: true,
envVar: 'MSTEAMS_APP_ID',
},
{
key: 'appPassword',
label: 'App Password',
label: 'channels:meta.msteams.fields.appPassword.label',
type: 'password',
placeholder: 'Your Microsoft App Password',
placeholder: 'channels:meta.msteams.fields.appPassword.placeholder',
required: true,
envVar: 'MSTEAMS_APP_PASSWORD',
},
],
instructions: [
'Go to Azure Portal',
'Register a new Bot application',
'Get App ID and create a password',
'Configure Teams channel',
'channels:meta.msteams.instructions.0',
'channels:meta.msteams.instructions.1',
'channels:meta.msteams.instructions.2',
'channels:meta.msteams.instructions.3',
],
isPlugin: true,
},
@@ -385,52 +385,52 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
id: 'googlechat',
name: 'Google Chat',
icon: '💭',
description: 'Connect Google Chat via webhook',
description: 'channels:meta.googlechat.description',
connectionType: 'webhook',
docsUrl: 'https://docs.openclaw.ai/channels/googlechat',
docsUrl: 'channels:meta.googlechat.docsUrl',
configFields: [
{
key: 'serviceAccountKey',
label: 'Service Account JSON Path',
label: 'channels:meta.googlechat.fields.serviceAccountKey.label',
type: 'text',
placeholder: '/path/to/service-account.json',
placeholder: 'channels:meta.googlechat.fields.serviceAccountKey.placeholder',
required: true,
},
],
instructions: [
'Create a Google Cloud project',
'Enable Google Chat API',
'Create a service account',
'Download the JSON key file',
'channels:meta.googlechat.instructions.0',
'channels:meta.googlechat.instructions.1',
'channels:meta.googlechat.instructions.2',
'channels:meta.googlechat.instructions.3',
],
},
mattermost: {
id: 'mattermost',
name: 'Mattermost',
icon: '💠',
description: 'Connect Mattermost via Bot API',
description: 'channels:meta.mattermost.description',
connectionType: 'token',
docsUrl: 'https://docs.openclaw.ai/channels/mattermost',
docsUrl: 'channels:meta.mattermost.docsUrl',
configFields: [
{
key: 'serverUrl',
label: 'Server URL',
label: 'channels:meta.mattermost.fields.serverUrl.label',
type: 'text',
placeholder: 'https://your-mattermost.com',
placeholder: 'channels:meta.mattermost.fields.serverUrl.placeholder',
required: true,
},
{
key: 'botToken',
label: 'Bot Access Token',
label: 'channels:meta.mattermost.fields.botToken.label',
type: 'password',
placeholder: 'Your bot access token',
placeholder: 'channels:meta.mattermost.fields.botToken.placeholder',
required: true,
},
],
instructions: [
'Go to Mattermost Integrations',
'Create a new Bot Account',
'Copy the access token',
'channels:meta.mattermost.instructions.0',
'channels:meta.mattermost.instructions.1',
'channels:meta.mattermost.instructions.2',
],
isPlugin: true,
},