refactor(setup): remove channel connection step from onboarding (#301)
This commit is contained in:
committed by
GitHub
Unverified
parent
d14ae0a8f5
commit
8eae7df3a1
@@ -18,7 +18,6 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
BookOpen,
|
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TitleBar } from '@/components/layout/TitleBar';
|
import { TitleBar } from '@/components/layout/TitleBar';
|
||||||
@@ -32,14 +31,6 @@ import { useSettingsStore } from '@/stores/settings';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
|
||||||
CHANNEL_META,
|
|
||||||
getPrimaryChannels,
|
|
||||||
type ChannelType,
|
|
||||||
type ChannelMeta,
|
|
||||||
type ChannelConfigField,
|
|
||||||
} from '@/types/channel';
|
|
||||||
|
|
||||||
interface SetupStep {
|
interface SetupStep {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -50,9 +41,8 @@ const STEP = {
|
|||||||
WELCOME: 0,
|
WELCOME: 0,
|
||||||
RUNTIME: 1,
|
RUNTIME: 1,
|
||||||
PROVIDER: 2,
|
PROVIDER: 2,
|
||||||
CHANNEL: 3,
|
INSTALLING: 3,
|
||||||
INSTALLING: 4,
|
COMPLETE: 4,
|
||||||
COMPLETE: 5,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const steps: SetupStep[] = [
|
const steps: SetupStep[] = [
|
||||||
@@ -71,11 +61,6 @@ const steps: SetupStep[] = [
|
|||||||
title: 'AI Provider',
|
title: 'AI Provider',
|
||||||
description: 'Configure your AI service',
|
description: 'Configure your AI service',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'channel',
|
|
||||||
title: 'Connect a Channel',
|
|
||||||
description: 'Connect a messaging platform (optional)',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'installing',
|
id: 'installing',
|
||||||
title: 'Setting Up',
|
title: 'Setting Up',
|
||||||
@@ -152,8 +137,6 @@ export function Setup() {
|
|||||||
return runtimeChecksPassed;
|
return runtimeChecksPassed;
|
||||||
case STEP.PROVIDER:
|
case STEP.PROVIDER:
|
||||||
return providerConfigured;
|
return providerConfigured;
|
||||||
case STEP.CHANNEL:
|
|
||||||
return true; // Always allow proceeding — channel step is optional
|
|
||||||
case STEP.INSTALLING:
|
case STEP.INSTALLING:
|
||||||
return false; // Cannot manually proceed, auto-proceeds when done
|
return false; // Cannot manually proceed, auto-proceeds when done
|
||||||
case STEP.COMPLETE:
|
case STEP.COMPLETE:
|
||||||
@@ -259,7 +242,6 @@ export function Setup() {
|
|||||||
onConfiguredChange={setProviderConfigured}
|
onConfiguredChange={setProviderConfigured}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{safeStepIndex === STEP.CHANNEL && <SetupChannelContent />}
|
|
||||||
{safeStepIndex === STEP.INSTALLING && (
|
{safeStepIndex === STEP.INSTALLING && (
|
||||||
<InstallingContent
|
<InstallingContent
|
||||||
skills={defaultSkills}
|
skills={defaultSkills}
|
||||||
@@ -287,12 +269,7 @@ export function Setup() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{safeStepIndex === STEP.CHANNEL && (
|
{!isLastStep && safeStepIndex !== STEP.RUNTIME && (
|
||||||
<Button variant="ghost" onClick={handleNext}>
|
|
||||||
{t('nav.skipStep')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isLastStep && safeStepIndex !== STEP.RUNTIME && safeStepIndex !== STEP.CHANNEL && (
|
|
||||||
<Button variant="ghost" onClick={handleSkip}>
|
<Button variant="ghost" onClick={handleSkip}>
|
||||||
{t('nav.skipSetup')}
|
{t('nav.skipSetup')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1347,263 +1324,6 @@ 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>>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const meta: ChannelMeta | null = selectedChannel ? CHANNEL_META[selectedChannel] : null;
|
|
||||||
const primaryChannels = getPrimaryChannels();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
if (!selectedChannel) return;
|
|
||||||
try {
|
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
|
||||||
'channel:getFormValues',
|
|
||||||
selectedChannel
|
|
||||||
) as { success: boolean; values?: Record<string, string> };
|
|
||||||
if (cancelled) return;
|
|
||||||
if (result.success && result.values) {
|
|
||||||
setConfigValues(result.values);
|
|
||||||
} else {
|
|
||||||
setConfigValues({});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setConfigValues({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const isFormValid = () => {
|
|
||||||
if (!meta) return false;
|
|
||||||
return meta.configFields
|
|
||||||
.filter((f: ChannelConfigField) => f.required)
|
|
||||||
.every((f: ChannelConfigField) => configValues[f.key]?.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!selectedChannel || !meta || !isFormValid()) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setValidationError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate credentials first
|
|
||||||
const validation = await window.electron.ipcRenderer.invoke(
|
|
||||||
'channel:validateCredentials',
|
|
||||||
selectedChannel,
|
|
||||||
configValues
|
|
||||||
) as { success: boolean; valid?: boolean; errors?: string[]; details?: Record<string, string> };
|
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
setValidationError((validation.errors || ['Validation failed']).join(', '));
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save config
|
|
||||||
await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedChannel, { ...configValues });
|
|
||||||
|
|
||||||
const botName = validation.details?.botUsername ? ` (@${validation.details.botUsername})` : '';
|
|
||||||
toast.success(`${meta.name} configured${botName}`);
|
|
||||||
setSaved(true);
|
|
||||||
} catch (error) {
|
|
||||||
setValidationError(String(error));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Already saved — show success
|
|
||||||
if (saved) {
|
|
||||||
return (
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<div className="text-5xl">✅</div>
|
|
||||||
<h2 className="text-xl font-semibold">
|
|
||||||
{t('channel.connected', { name: meta?.name || 'Channel' })}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{t('channel.connectedDesc')}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
setSaved(false);
|
|
||||||
setSelectedChannel(null);
|
|
||||||
setConfigValues({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('channel.configureAnother')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel type not selected — show picker
|
|
||||||
if (!selectedChannel) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="text-center mb-2">
|
|
||||||
<div className="text-4xl mb-3">📡</div>
|
|
||||||
<h2 className="text-xl font-semibold">{t('channel.title')}</h2>
|
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
|
||||||
{t('channel.subtitle')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{primaryChannels.map((type) => {
|
|
||||||
const channelMeta = CHANNEL_META[type];
|
|
||||||
if (channelMeta.connectionType !== 'token') return null;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
onClick={() => setSelectedChannel(type)}
|
|
||||||
className="p-4 rounded-lg bg-muted/50 hover:bg-muted transition-all text-left"
|
|
||||||
>
|
|
||||||
<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 line-clamp-2">
|
|
||||||
{t(channelMeta.description)}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel selected — show config form
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedChannel(null); setConfigValues({}); setValidationError(null); }}
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
|
||||||
<span>{meta?.icon}</span> {t('channel.configure', { name: meta?.name })}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground text-sm mt-1">{t(meta?.description || '')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instructions */}
|
|
||||||
<div className="p-3 rounded-lg bg-muted/50 text-sm">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="font-medium text-foreground">{t('channel.howTo')}</p>
|
|
||||||
{meta?.docsUrl && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
try {
|
|
||||||
const url = t(meta.docsUrl!);
|
|
||||||
if (window.electron?.openExternal) {
|
|
||||||
window.electron.openExternal(url);
|
|
||||||
} else {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
|
||||||
>
|
|
||||||
<BookOpen className="h-3 w-3" />
|
|
||||||
{t('channel.viewDocs')}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ol className="list-decimal list-inside text-muted-foreground space-y-1">
|
|
||||||
{meta?.instructions.map((inst, i) => (
|
|
||||||
<li key={i}>{t(inst)}</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Config fields */}
|
|
||||||
{meta?.configFields.map((field: ChannelConfigField) => {
|
|
||||||
const isPassword = field.type === 'password';
|
|
||||||
return (
|
|
||||||
<div key={field.key} className="space-y-1.5">
|
|
||||||
<Label htmlFor={`setup-${field.key}`} className="text-foreground">
|
|
||||||
{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 ? t(field.placeholder) : undefined}
|
|
||||||
value={configValues[field.key] || ''}
|
|
||||||
onChange={(e) => setConfigValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
|
||||||
autoComplete="off"
|
|
||||||
className="font-mono text-sm bg-background border-input"
|
|
||||||
/>
|
|
||||||
{isPassword && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => setShowSecrets((prev) => ({ ...prev, [field.key]: !prev[field.key] }))}
|
|
||||||
>
|
|
||||||
{showSecrets[field.key] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{field.description && (
|
|
||||||
<p className="text-xs text-slate-500 mt-1">{t(field.description)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Validation error */}
|
|
||||||
{validationError && (
|
|
||||||
<div className="p-3 rounded-lg bg-red-900/30 border border-red-500/30 text-sm text-red-300 flex items-start gap-2">
|
|
||||||
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<span>{validationError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save button */}
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!isFormValid() || saving}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
{t('provider.validateSave')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-2" />
|
|
||||||
{t('provider.validateSave')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: SkillsContent component removed - auto-install essential skills
|
// NOTE: SkillsContent component removed - auto-install essential skills
|
||||||
|
|
||||||
// Installation status for each skill
|
// Installation status for each skill
|
||||||
|
|||||||
Reference in New Issue
Block a user