Files
zclaw_openfang/desktop/src/components/AgentOnboardingWizard.tsx
iven 6c64d704d7
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
docs: add self-evolution documentation and fix SOUL.md persistence
- Create 01-identity-evolution.md: Identity system architecture (SOUL.md, USER.md, change proposals, version management)
- Create 04-heartbeat-engine.md: Proactive behavior system (heartbeat config, alerts, proactivity levels)
- Create 06-context-compaction.md: Context compression system (token management, summarization, information retention)
- Update ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md: Add Phase 5 self-evolution UX roadmap
- Fix AgentOnboardingWizard: Persist SOUL.md and USER.md after agent creation
- Fix llm-service: Add Tauri kernel mode detection for memory system LLM calls
- Fix kernel: Kernel config takes priority over agent's persisted model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 00:38:31 +08:00

677 lines
27 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,
} 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';
// === 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: 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, updateClone, clones, isLoading, error, clearError } = useAgentStore();
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<WizardFormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
setFormData(initialFormData);
setCurrentStep(1);
setErrors({});
setSubmitStatus('idle');
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;
});
}
};
// Validate current step
const validateStep = useCallback((step: number): boolean => {
const newErrors: Record<string, string> = {};
switch (step) {
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, 1));
};
// Handle form submission
const handleSubmit = async () => {
if (!validateStep(currentStep)) {
return;
}
setSubmitStatus('idle');
try {
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,
};
let clone: Clone | undefined;
// If there's an existing clone, update it instead of creating a new one
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
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,
});
// Write SOUL.md (agent personality)
await intelligenceClient.identity.updateFile(clone.id, 'soul', soulContent);
// Write USER.md (user profile)
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
console.log('[Onboarding] SOUL.md and USER.md persisted for agent:', clone.id);
} catch (err) {
console.warn('[Onboarding] Failed to persist identity files:', err);
// Don't fail the whole onboarding if identity persistence fails
}
setSubmitStatus('success');
setTimeout(() => {
onSuccess?.(clone);
onClose();
}, 1500);
} else {
setSubmitStatus('error');
}
} catch {
setSubmitStatus('error');
}
};
if (!isOpen) return null;
const CurrentStepIcon = steps[currentStep - 1]?.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}/{steps.length}: {steps[currentStep - 1]?.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 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 === 1}
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;