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

@@ -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>