feat(app): i18n (#48)
This commit is contained in:
committed by
GitHub
Unverified
parent
505a64438e
commit
6e09a69f4f
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user