fix(saas): deep audit round industry template system - critical fixes
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

C1: Use backend createAgentFromTemplate API + tools forwarding
C3: seed source='builtin' instead of 'custom'
C4: immutable clone data handling (return fresh from store) + spread)
H3: assignTemplate error propagation (try/catch)
H4: input validation for name/fields
H5: assign_template account existence check
H6: remove dead route get_full_template
H7: model fallback gpt-4o-mini (hardcoded constant)
H8: logout clears template state
H9: console.warn -> structured logger
C2: restoreSession fetches assignedTemplate
This commit is contained in:
iven
2026-04-03 19:45:25 +08:00
parent 0857a1f608
commit edecd4c81f
5 changed files with 62 additions and 802 deletions

View File

@@ -1,144 +1,9 @@
/**
* 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)
useSaaSStore.getState().assignTemplate(t.id).catch((err: unknown) => {
log.warn('Failed to assign template to account:', err);
});
// 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 {
@@ -146,620 +11,3 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
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;

View File

@@ -7,8 +7,12 @@
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
import type { AgentTemplateFull } from '../lib/saas-client';
import { saasClient } from '../lib/saas-client';
import { useChatStore } from './chatStore';
import { useConversationStore } from './chat/conversationStore';
import { createLogger } from '../lib/logger';
const log = createLogger('AgentStore');
// === Types ===
@@ -134,7 +138,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
loadClones: async () => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized, skipping loadClones');
log.warn('[AgentStore] Client not initialized, skipping loadClones');
return;
}
@@ -163,7 +167,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
createClone: async (opts: CloneCreateOptions) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
log.warn('[AgentStore] Client not initialized');
return undefined;
}
@@ -181,76 +185,79 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
createFromTemplate: async (template: AgentTemplateFull) => {
const client = getClient();
if (!client) return undefined;
if (!client) {
set({ error: 'Client not initialized' });
return undefined;
}
set({ isLoading: true, error: null });
try {
// Step 1: Call backend to get server-processed config (tools merge, model fallback)
const config = await saasClient.createAgentFromTemplate(template.id);
// Step 2: Create clone with merged data from backend
const result = await client.createClone({
name: template.name,
emoji: template.emoji,
personality: template.personality,
name: config.name,
emoji: config.emoji,
personality: config.personality,
scenarios: template.scenarios,
communicationStyle: template.communication_style,
model: template.model,
communicationStyle: config.communication_style,
model: config.model,
});
const cloneId = result?.clone?.id;
if (cloneId) {
// Persist SOUL.md via identity system
if (template.soul_content) {
if (config.soul_content) {
try {
const { intelligenceClient } = await import('../lib/intelligence-client');
await intelligenceClient.identity.updateFile(cloneId, 'soul', template.soul_content);
await intelligenceClient.identity.updateFile(cloneId, 'soul', config.soul_content);
} catch (e) {
console.warn('Failed to persist soul_content:', e);
log.warn('Failed to persist soul_content:', e);
}
}
// Persist system_prompt via identity system
if (template.system_prompt) {
if (config.system_prompt) {
try {
const { intelligenceClient } = await import('../lib/intelligence-client');
await intelligenceClient.identity.updateFile(cloneId, 'system', template.system_prompt);
await intelligenceClient.identity.updateFile(cloneId, 'system', config.system_prompt);
} catch (e) {
console.warn('Failed to persist system_prompt:', e);
log.warn('Failed to persist system_prompt:', e);
}
}
// Persist temperature / max_tokens if supported
if (template.temperature != null || template.max_tokens != null) {
try {
await client.updateClone(cloneId, {
temperature: template.temperature,
maxTokens: template.max_tokens,
});
} catch (e) {
console.warn('Failed to persist temperature/max_tokens:', e);
}
}
// Persist temperature / max_tokens / tools / source_template_id / welcomeMessage / quickCommands
const metadata: Record<string, unknown> = {};
if (config.temperature != null) metadata.temperature = config.temperature;
if (config.max_tokens != null) metadata.maxTokens = config.max_tokens;
if (config.tools?.length) metadata.tools = config.tools;
metadata.source_template_id = template.id;
if (config.welcome_message) metadata.welcomeMessage = config.welcome_message;
if (config.quick_commands?.length) metadata.quickCommands = config.quick_commands;
// Persist welcome_message + quick_commands as clone metadata
if (template.welcome_message || (template.quick_commands && template.quick_commands.length > 0)) {
if (Object.keys(metadata).length > 0) {
try {
await client.updateClone(cloneId, {
welcomeMessage: template.welcome_message || '',
quickCommands: template.quick_commands || [],
});
await client.updateClone(cloneId, metadata);
} catch (e) {
console.warn('Failed to persist welcome_message/quick_commands:', e);
log.warn('Failed to persist clone metadata:', e);
}
}
}
await get().loadClones();
// Merge template welcome/quick data into the returned clone for immediate use
const createdClone = result?.clone as Record<string, unknown> | undefined;
if (createdClone) {
if (template.welcome_message) createdClone.welcomeMessage = template.welcome_message;
if (template.quick_commands?.length) createdClone.quickCommands = template.quick_commands;
// Return a fresh clone from the store (immutable — no in-place mutation)
const freshClone = get().clones.find((c) => c.id === cloneId);
if (freshClone) {
return {
...freshClone,
...(config.welcome_message ? { welcomeMessage: config.welcome_message } : {}),
...(config.quick_commands?.length ? { quickCommands: config.quick_commands } : {}),
};
}
return createdClone as Clone | undefined;
return result?.clone as Clone | undefined;
} catch (error) {
set({ error: String(error) });
return undefined;
@@ -262,7 +269,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
updateClone: async (id: string, updates: Partial<Clone>) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
log.warn('[AgentStore] Client not initialized');
return undefined;
}
@@ -281,7 +288,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
deleteClone: async (id: string) => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized');
log.warn('[AgentStore] Client not initialized');
return;
}
@@ -325,7 +332,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
loadPluginStatus: async () => {
const client = getClient();
if (!client) {
console.warn('[AgentStore] Client not initialized, skipping loadPluginStatus');
log.warn('[AgentStore] Client not initialized, skipping loadPluginStatus');
return;
}

View File

@@ -397,8 +397,10 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
authToken: null,
connectionMode: 'tauri',
availableModels: [],
availableTemplates: [],
assignedTemplate: null,
error: null,
totpRequired: false,
toTopRequired: false,
totpSetupData: null,
});
},
@@ -720,6 +722,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().fetchAvailableTemplates().catch(() => {});
get().fetchAssignedTemplate().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});