feat(desktop): integrate SaaS llm_routing, template API, and onboarding template selection

- Add AgentTemplateAvailable/AgentTemplateFull types and fetchAvailableTemplates/fetchTemplateFull API methods to saas-client
- Add llm_routing field to SaaSAccountInfo for admin-configured routing priority
- Add availableTemplates state and fetchAvailableTemplates action to saasStore with background fetch on login
- Add admin llm_routing priority check in connectionStore connect() to force relay or local mode
- Add createFromTemplate action to agentStore with SOUL.md persistence
- Add Step 0 template selection to AgentOnboardingWizard with grid layout for template browsing
This commit is contained in:
iven
2026-03-31 03:15:45 +08:00
parent 9fb9c3204c
commit c9b9c5231b
6 changed files with 927 additions and 1025 deletions

View File

@@ -18,6 +18,7 @@ import {
Check,
Loader2,
AlertCircle,
LayoutGrid,
} from 'lucide-react';
import { cn } from '../lib/utils';
import { useAgentStore, type CloneCreateOptions } from '../store/agentStore';
@@ -28,6 +29,12 @@ 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');
@@ -72,6 +79,7 @@ const initialFormData: WizardFormData = {
// === 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 },
@@ -82,19 +90,21 @@ const steps = [
// === Component ===
export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboardingWizardProps) {
const { createClone, updateClone, clones, isLoading, error, clearError } = useAgentStore();
const [currentStep, setCurrentStep] = useState(1);
const { createClone, createFromTemplate, updateClone, clones, isLoading, error, clearError } = useAgentStore();
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(1);
setCurrentStep(0);
setErrors({});
setSubmitStatus('idle');
setSelectedTemplate(null);
clearError();
}
}, [isOpen, clearError]);
@@ -111,11 +121,33 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
}
};
// 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,
}));
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 = '请输入您的名字';
@@ -157,7 +189,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
// Navigate to previous step
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
// Handle form submission
@@ -169,59 +201,76 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
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);
// 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 {
const createOptions: CloneCreateOptions = {
...personalityUpdates,
privacyOptIn: formData.privacyOptIn,
// 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,
};
clone = await createClone(createOptions);
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,
});
// 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,
});
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);
await intelligenceClient.identity.updateFile(clone.id, 'soul', soulContent);
await intelligenceClient.identity.updateFile(clone.id, 'user_profile', userContent);
// Write USER.md (user profile)
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);
// Don't fail the whole onboarding if identity persistence fails
log.debug('SOUL.md and USER.md persisted for agent:', clone.id);
} catch (err) {
log.warn('Failed to persist identity files:', err);
}
}
setSubmitStatus('success');
@@ -239,7 +288,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
if (!isOpen) return null;
const CurrentStepIcon = steps[currentStep - 1]?.icon || Bot;
const CurrentStepIcon = steps[currentStep]?.icon || Bot;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -262,7 +311,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
Agent
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{currentStep}/{steps.length}: {steps[currentStep - 1]?.title}
{currentStep + 1}/{steps.length}: {steps[currentStep]?.title}
</p>
</div>
</div>
@@ -321,6 +370,36 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
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>
{useSaaSStore.getState().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 && (
<>
@@ -627,7 +706,7 @@ export function AgentOnboardingWizard({ isOpen, onClose, onSuccess }: AgentOnboa
<button
type="button"
onClick={prevStep}
disabled={currentStep === 1}
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" />