Files
zclaw_openfang/desktop/src/components/AgentOnboardingWizard.tsx
iven 4281ce35b4
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(saas): remove hardcoded model fallback — dynamic from available models
- service.rs: template model passed as-is (Option<String>), no hardcoded fallback
- saas-types.ts: AgentConfigFromTemplate.model → string | null
- agentStore.ts: when model is null, resolve from saasStore.availableModels[0]
- AgentOnboardingWizard.tsx: restore full file (was corrupted), apply assignTemplate try/catch fix
2026-04-03 21:38:15 +08:00

768 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AgentOnboardingWizard - Guided Agent creation wizard
*
* A 5-step wizard for creating new Agents with personality settings.
* Inspired by OpenClaw's quick configuration modal.
*/
import { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
User,
Bot,
Sparkles,
Briefcase,
Folder,
ChevronLeft,
ChevronRight,
Check,
Loader2,
AlertCircle,
LayoutGrid,
} from 'lucide-react';
import { cn } from '../lib/utils';
import { useAgentStore, type CloneCreateOptions } from '../store/agentStore';
import { EmojiPicker } from './ui/EmojiPicker';
import { PersonalitySelector } from './PersonalitySelector';
import { ScenarioTags } from './ScenarioTags';
import type { Clone } from '../store/agentStore';
import { intelligenceClient } from '../lib/intelligence-client';
import { generateSoulContent, generateUserContent } from '../lib/personality-presets';
import { createLogger } from '../lib/logger';
import {
type AgentTemplateAvailable,
type AgentTemplateFull,
saasClient,
} from '../lib/saas-client';
import { useSaaSStore } from '../store/saasStore';
const log = createLogger('AgentOnboardingWizard');
// === Types ===
interface WizardFormData {
userName: string;
userRole: string;
agentName: string;
agentRole: string;
agentNickname: string;
emoji: string;
personality: string;
scenarios: string[];
workspaceDir: string;
restrictFiles: boolean;
privacyOptIn: boolean;
notes: string;
}
interface AgentOnboardingWizardProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: (clone: Clone) => void;
}
const initialFormData: WizardFormData = {
userName: '',
userRole: '',
agentName: '',
agentRole: '',
agentNickname: '',
emoji: '',
personality: '',
scenarios: [],
workspaceDir: '',
restrictFiles: true,
privacyOptIn: false,
notes: '',
};
// === Step Configuration ===
const steps = [
{ id: 0, title: '行业模板', description: '选择预设或自定义', icon: LayoutGrid },
{ id: 1, title: '认识用户', description: '让我们了解一下您', icon: User },
{ id: 2, title: 'Agent 身份', description: '给助手起个名字', icon: Bot },
{ id: 3, title: '人格风格', description: '选择沟通风格', icon: Sparkles },
{ id: 4, title: '使用场景', description: '选择应用场景', icon: Briefcase },
{ id: 5, title: '工作环境', description: '配置工作目录', icon: Folder },
];
// === Component ===
export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboardingWizardProps) {
const { createClone, createFromTemplate, updateClone, clones, isLoading, error, clearError } = useAgentStore();
const availableTemplates = useSaaSStore((s) => s.availableTemplates);
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<WizardFormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [selectedTemplate, setSelectedTemplate] = useState<AgentTemplateFull | null>(null);
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
setFormData(initialFormData);
setCurrentStep(0);
setErrors({});
setSubmitStatus('idle');
setSelectedTemplate(null);
clearError();
}
}, [isOpen, clearError]);
// Update form field
const updateField = <K extends keyof WizardFormData>(field: K, value: WizardFormData[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Handle template selection
const handleSelectTemplate = async (t: AgentTemplateAvailable) => {
try {
const full = await saasClient.fetchTemplateFull(t.id);
setSelectedTemplate(full);
setFormData(prev => ({
...prev,
agentName: full.name,
emoji: full.emoji || prev.emoji,
personality: full.personality || prev.personality,
scenarios: full.scenarios.length > 0 ? full.scenarios : prev.scenarios,
}));
// Persist template assignment to SaaS backend (fire-and-forget, let wizard continue with fallback flow)
try {
await useSaaSStore.getState().assignTemplate(t.id);
} catch (err: unknown) {
log.warn('Template assignment failed:', err);
}
setCurrentStep(1);
} catch {
// If fetch fails, still allow manual creation
setCurrentStep(1);
}
};
// Validate current step
const validateStep = useCallback((step: number): boolean => {
const newErrors: Record<string, string> = {};
switch (step) {
case 0:
// Template selection is always valid (blank agent is an option)
break;
case 1:
if (!formData.userName.trim()) {
newErrors.userName = '请输入您的名字';
}
break;
case 2:
if (!formData.agentName.trim()) {
newErrors.agentName = '请输入 Agent 名称';
}
break;
case 3:
if (!formData.emoji) {
newErrors.emoji = '请选择一个 Emoji';
}
if (!formData.personality) {
newErrors.personality = '请选择一个人格风格';
}
break;
case 4:
if (formData.scenarios.length === 0) {
newErrors.scenarios = '请至少选择一个使用场景';
}
break;
case 5:
// Optional step, no validation
break;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
// Navigate to next step
const nextStep = () => {
if (validateStep(currentStep)) {
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
}
};
// Navigate to previous step
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
// Handle form submission
const handleSubmit = async () => {
if (!validateStep(currentStep)) {
return;
}
setSubmitStatus('idle');
try {
let clone: Clone | undefined;
// Template-based creation path
if (selectedTemplate && clones.length === 0) {
clone = await createFromTemplate(selectedTemplate);
// Persist USER.md for template-created agents
if (clone) {
try {
const userContent = generateUserContent({
userName: formData.userName,
userRole: formData.userRole,
scenarios: formData.scenarios,
});
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
} catch (err) {
log.warn('Failed to persist USER.md for template agent:', err);
}
}
} else {
// Manual creation / update path
const personalityUpdates = {
name: formData.agentName,
role: formData.agentRole || undefined,
nickname: formData.agentNickname || undefined,
userName: formData.userName,
userRole: formData.userRole || undefined,
scenarios: formData.scenarios,
workspaceDir: formData.workspaceDir || undefined,
restrictFiles: formData.restrictFiles,
emoji: formData.emoji,
personality: formData.personality,
notes: formData.notes || undefined,
};
if (clones && clones.length > 0) {
clone = await updateClone(clones[0].id, personalityUpdates);
} else {
const createOptions: CloneCreateOptions = {
...personalityUpdates,
privacyOptIn: formData.privacyOptIn,
};
clone = await createClone(createOptions);
}
}
if (clone) {
// Persist SOUL.md and USER.md to the identity system (manual path only)
if (!selectedTemplate) {
try {
const soulContent = generateSoulContent({
agentName: formData.agentName,
emoji: formData.emoji,
personality: formData.personality,
scenarios: formData.scenarios,
});
const userContent = generateUserContent({
userName: formData.userName,
userRole: formData.userRole,
scenarios: formData.scenarios,
});
await intelligenceClient.identity.updateFile(clone.id, 'soul', soulContent);
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
log.debug('SOUL.md and USER.md persisted for agent:', clone.id);
} catch (err) {
log.warn('Failed to persist identity files:', err);
}
}
setSubmitStatus('success');
setTimeout(() => {
onSuccess?.(clone);
onClose();
}, 1500);
} else {
setSubmitStatus('error');
}
} catch {
setSubmitStatus('error');
}
};
if (!isOpen) return null;
const CurrentStepIcon = steps[currentStep]?.icon || Bot;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<CurrentStepIcon className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Agent
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{currentStep + 1}/{steps.length}: {steps[currentStep]?.title}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center gap-1">
{steps.map((step, index) => {
const StepIcon = step.icon;
const isActive = currentStep === step.id;
const isCompleted = currentStep > step.id;
return (
<div key={step.id} className="flex items-center flex-1">
<button
type="button"
onClick={() => currentStep > step.id && setCurrentStep(step.id)}
disabled={currentStep <= step.id}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-all',
isActive && 'bg-primary text-white',
isCompleted && 'bg-primary/20 text-primary cursor-pointer',
!isActive && !isCompleted && 'bg-gray-100 dark:bg-gray-700 text-gray-400'
)}
>
{isCompleted ? <Check className="w-4 h-4" /> : <StepIcon className="w-4 h-4" />}
</button>
{index < steps.length - 1 && (
<div
className={cn(
'flex-1 h-1 rounded-full mx-1',
isCompleted ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
)}
/>
)}
</div>
);
})}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
{/* Step 0: 行业模板 */}
{currentStep === 0 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground"> Agent</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<button
type="button"
onClick={() => { setSelectedTemplate(null); setCurrentStep(1); }}
className="p-4 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors text-center"
>
<div className="text-2xl mb-2">&#x2728;</div>
<div className="font-medium text-sm"> Agent</div>
<div className="text-xs text-muted-foreground"></div>
</button>
{(availableTemplates ?? []).map(t => (
<button
key={t.id}
type="button"
onClick={() => handleSelectTemplate(t)}
className="p-4 rounded-lg border-2 hover:border-primary/50 transition-colors text-center"
>
<div className="text-2xl mb-2">{t.emoji || '🤖'}</div>
<div className="font-medium text-sm">{t.name}</div>
<div className="text-xs text-muted-foreground line-clamp-2">{t.description}</div>
</button>
))}
</div>
</div>
)}
{/* Step 1: 认识用户 */}
{currentStep === 1 && (
<>
<div className="text-center mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.userName}
onChange={(e) => updateField('userName', e.target.value)}
placeholder="例如:张三"
className={cn(
'w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary',
errors.userName ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
)}
/>
{errors.userName && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.userName}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={formData.userRole}
onChange={(e) => updateField('userRole', e.target.value)}
placeholder="例如:产品经理、开发工程师"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</>
)}
{/* Step 2: Agent 身份 */}
{currentStep === 2 && (
<>
<div className="text-center mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Agent <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.agentName}
onChange={(e) => updateField('agentName', e.target.value)}
placeholder="例如:小龙助手"
className={cn(
'w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary',
errors.agentName ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
)}
/>
{errors.agentName && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.agentName}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Agent
</label>
<input
type="text"
value={formData.agentRole}
onChange={(e) => updateField('agentRole', e.target.value)}
placeholder="例如:编程助手、写作顾问"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={formData.agentNickname}
onChange={(e) => updateField('agentNickname', e.target.value)}
placeholder="例如:小龙"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</>
)}
{/* Step 3: 人格风格 */}
{currentStep === 3 && (
<>
<div className="text-center mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Emoji <span className="text-red-500">*</span>
</label>
<EmojiPicker
value={formData.emoji}
onChange={(emoji) => updateField('emoji', emoji)}
/>
{errors.emoji && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.emoji}
</p>
)}
</div>
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<span className="text-red-500">*</span>
</label>
<PersonalitySelector
value={formData.personality}
onChange={(personality) => updateField('personality', personality)}
/>
{errors.personality && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.personality}
</p>
)}
</div>
</>
)}
{/* Step 4: 使用场景 */}
{currentStep === 4 && (
<>
<div className="text-center mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
使
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Agent 5
</p>
</div>
<ScenarioTags
value={formData.scenarios}
onChange={(scenarios) => updateField('scenarios', scenarios)}
maxSelections={5}
/>
{errors.scenarios && (
<p className="mt-2 text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{errors.scenarios}
</p>
)}
</>
)}
{/* Step 5: 工作环境 */}
{currentStep === 5 && (
<>
<div className="text-center mb-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Agent
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={formData.workspaceDir}
onChange={(e) => updateField('workspaceDir', e.target.value)}
placeholder="例如:/home/user/projects/myproject"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary font-mono"
/>
<p className="mt-1 text-xs text-gray-400">
Agent 使
</p>
</div>
<div className="space-y-3 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
访
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
访
</p>
</div>
<button
type="button"
onClick={() => updateField('restrictFiles', !formData.restrictFiles)}
className={cn(
'w-11 h-6 rounded-full transition-colors relative',
formData.restrictFiles ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
)}
>
<span
className={cn(
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
)}
style={{ left: formData.restrictFiles ? '22px' : '2px' }}
/>
</button>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
placeholder="关于此 Agent 的备注信息..."
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary resize-none"
/>
</div>
{/* Summary Preview */}
<div className="p-4 bg-primary/5 rounded-lg mt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-2xl">{formData.emoji || '🤖'}</span>
<span className="font-medium">{formData.agentName || '未命名'}</span>
{formData.agentNickname && (
<span className="text-gray-500">({formData.agentNickname})</span>
)}
</div>
<div className="text-gray-600 dark:text-gray-400">
{formData.userName}
{formData.userRole && ` (${formData.userRole})`}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{formData.scenarios.map((id) => (
<span
key={id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
>
{id}
</span>
))}
</div>
</div>
</div>
{/* Status Messages */}
{submitStatus === 'success' && (
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400 mt-4">
<Check className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">Agent </span>
</div>
)}
{submitStatus === 'error' && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700 dark:text-red-400 mt-4">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">{error || '创建失败,请重试'}</span>
</div>
)}
</>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button
type="button"
onClick={prevStep}
disabled={currentStep === 0}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-2">
{currentStep < steps.length ? (
<button
type="button"
onClick={nextStep}
className="px-4 py-2 text-sm bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<ChevronRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || submitStatus === 'success'}
className="px-4 py-2 text-sm bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : submitStatus === 'success' ? (
<>
<Check className="w-4 h-4" />
</>
) : (
<>
<Check className="w-4 h-4" />
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
);
}
export default AgentOnboardingWizard;