refactor(types): comprehensive TypeScript type system improvements

Major type system refactoring and error fixes across the codebase:

**Type System Improvements:**
- Extended OpenFangStreamEvent with 'connected' and 'agents_updated' event types
- Added GatewayPong interface for WebSocket pong responses
- Added index signature to MemorySearchOptions for Record compatibility
- Fixed RawApproval interface with hand_name, run_id properties

**Gateway & Protocol Fixes:**
- Fixed performHandshake nonce handling in gateway-client.ts
- Fixed onAgentStream callback type definitions
- Fixed HandRun runId mapping to handle undefined values
- Fixed Approval mapping with proper default values

**Memory System Fixes:**
- Fixed MemoryEntry creation with required properties (lastAccessedAt, accessCount)
- Replaced getByAgent with getAll method in vector-memory.ts
- Fixed MemorySearchOptions type compatibility

**Component Fixes:**
- Fixed ReflectionLog property names (filePath→file, proposedContent→suggestedContent)
- Fixed SkillMarket suggestSkills async call arguments
- Fixed message-virtualization useRef generic type
- Fixed session-persistence messageCount type conversion

**Code Cleanup:**
- Removed unused imports and variables across multiple files
- Consolidated StoredError interface (removed duplicate)
- Deleted obsolete test files (feedbackStore.test.ts, memory-index.test.ts)

**New Features:**
- Added browser automation module (Tauri backend)
- Added Active Learning Panel component
- Added Agent Onboarding Wizard
- Added Memory Graph visualization
- Added Personality Selector
- Added Skill Market store and components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-17 08:05:07 +08:00
parent adfd7024df
commit f4efc823e2
80 changed files with 9496 additions and 1390 deletions

View File

@@ -0,0 +1,409 @@
/**
* ActiveLearningPanel - 主动学习状态面板
*
* 展示学习事件、模式和系统建议。
*/
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Brain,
TrendingUp,
Lightbulb,
Check,
X,
RefreshCw,
Download,
Upload,
Settings,
BarChart3,
Clock,
Zap,
} from 'lucide-react';
import { Button, Badge, EmptyState } from './ui';
import {
useActiveLearningStore,
type LearningEvent,
type LearningPattern,
type LearningSuggestion,
type LearningEventType,
} from '../store/activeLearningStore';
import { useChatStore } from '../store/chatStore';
import { cardHover, defaultTransition } from '../lib/animations';
// === Constants ===
const EVENT_TYPE_LABELS: Record<LearningEventType, { label: string; color: string }> = {
preference: { label: '偏好', color: 'text-amber-400' },
correction: { label: '纠正', color: 'text-red-400' },
context: { label: '上下文', color: 'text-purple-400' },
feedback: { label: '反馈', color: 'text-blue-400' },
behavior: { label: '行为', color: 'text-green-400' },
implicit: { label: '隐式', color: 'text-gray-400' },
};
const PATTERN_TYPE_LABELS: Record<string, { label: string; icon: string }> = {
preference: { label: '偏好模式', icon: '🎯' },
rule: { label: '规则模式', icon: '📋' },
context: { label: '上下文模式', icon: '🔗' },
behavior: { label: '行为模式', icon: '⚡' },
};
// === Sub-Components ===
interface EventItemProps {
event: LearningEvent;
onAcknowledge: () => void;
}
function EventItem({ event, onAcknowledge }: EventItemProps) {
const typeInfo = EVENT_TYPE_LABELS[event.type];
const timeAgo = getTimeAgo(event.timestamp);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`p-3 rounded-lg border ${
event.acknowledged
? 'bg-gray-800/30 border-gray-700'
: 'bg-blue-900/20 border-blue-700'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="ghost" className={typeInfo.color}>
{typeInfo.label}
</Badge>
<span className="text-xs text-gray-500">{timeAgo}</span>
</div>
<p className="text-sm text-gray-300 truncate">{event.observation}</p>
{event.inferredPreference && (
<p className="text-xs text-gray-500 mt-1"> {event.inferredPreference}</p>
)}
</div>
{!event.acknowledged && (
<Button variant="ghost" size="sm" onClick={onAcknowledge}>
<Check className="w-4 h-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
<span>: {(event.confidence * 100).toFixed(0)}%</span>
{event.appliedCount > 0 && (
<span> {event.appliedCount} </span>
)}
</div>
</motion.div>
);
}
interface SuggestionCardProps {
suggestion: LearningSuggestion;
onApply: () => void;
onDismiss: () => void;
}
function SuggestionCard({ suggestion, onApply, onDismiss }: SuggestionCardProps) {
const daysLeft = Math.ceil(
(suggestion.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="p-4 bg-gradient-to-r from-amber-900/20 to-transparent rounded-lg border border-amber-700/50"
>
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200">{suggestion.suggestion}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
<span>: {(suggestion.confidence * 100).toFixed(0)}%</span>
{daysLeft > 0 && <span> {daysLeft} </span>}
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<Button variant="primary" size="sm" onClick={onApply}>
<Check className="w-3 h-3 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={onDismiss}>
<X className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
</motion.div>
);
}
// === Main Component ===
interface ActiveLearningPanelProps {
className?: string;
}
export function ActiveLearningPanel({ className = '' }: ActiveLearningPanelProps) {
const { currentAgent } = useChatStore();
const agentId = currentAgent?.id || 'default';
const [activeTab, setActiveTab] = useState<'events' | 'patterns' | 'suggestions'>('suggestions');
const {
events,
patterns,
suggestions,
config,
isLoading,
acknowledgeEvent,
getPatterns,
getSuggestions,
applySuggestion,
dismissSuggestion,
getStats,
setConfig,
exportLearningData,
clearEvents,
} = useActiveLearningStore();
const stats = getStats(agentId);
const agentEvents = events.filter(e => e.agentId === agentId).slice(0, 20);
const agentPatterns = getPatterns(agentId);
const agentSuggestions = getSuggestions(agentId);
// 处理确认事件
const handleAcknowledge = useCallback((eventId: string) => {
acknowledgeEvent(eventId);
}, [acknowledgeEvent]);
// 处理应用建议
const handleApplySuggestion = useCallback((suggestionId: string) => {
applySuggestion(suggestionId);
}, [applySuggestion]);
// 处理忽略建议
const handleDismissSuggestion = useCallback((suggestionId: string) => {
dismissSuggestion(suggestionId);
}, [dismissSuggestion]);
// 导出学习数据
const handleExport = useCallback(async () => {
const data = await exportLearningData(agentId);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `zclaw-learning-${agentId}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}, [agentId, exportLearningData]);
// 清除学习数据
const handleClear = useCallback(() => {
if (confirm('确定要清除所有学习数据吗?此操作不可撤销。')) {
clearEvents(agentId);
}
}, [agentId, clearEvents]);
return (
<div className={`flex flex-col h-full bg-gray-900 ${className}`}>
{/* 夨览栏 */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<Brain className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-white"></h2>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => setConfig({ enabled: e.target.checked })}
className="rounded"
/>
</label>
<Button variant="ghost" size="sm" onClick={handleExport}>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
{/* 统计概览 */}
<div className="grid grid-cols-4 gap-2 p-3 bg-gray-800/30">
<div className="text-center">
<div className="text-2xl font-bold text-blue-400">{stats.totalEvents}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-400">{stats.totalPatterns}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-amber-400">{agentSuggestions.length}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-400">
{(stats.avgConfidence * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
{/* Tab 切换 */}
<div className="flex border-b border-gray-800">
{(['suggestions', 'events', 'patterns'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{tab === 'suggestions' && '建议'}
{tab === 'events' && '事件'}
{tab === 'patterns' && '模式'}
</button>
))}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-3">
<AnimatePresence mode="wait">
{activeTab === 'suggestions' && (
<motion.div
key="suggestions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-3"
>
{agentSuggestions.length === 0 ? (
<EmptyState
icon={<Lightbulb className="w-12 h-12" />}
title="暂无学习建议"
description="系统会根据您的反馈自动生成改进建议"
/>
) : (
agentSuggestions.map(suggestion => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onApply={() => handleApplySuggestion(suggestion.id)}
onDismiss={() => handleDismissSuggestion(suggestion.id)}
/>
))
)}
</motion.div>
)}
{activeTab === 'events' && (
<motion.div
key="events"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-2"
>
{agentEvents.length === 0 ? (
<EmptyState
icon={<Clock className="w-12 h-12" />}
title="暂无学习事件"
description="开始对话后,系统会自动记录学习事件"
/>
) : (
agentEvents.map(event => (
<EventItem
key={event.id}
event={event}
onAcknowledge={() => handleAcknowledge(event.id)}
/>
))
)}
</motion.div>
)}
{activeTab === 'patterns' && (
<motion.div
key="patterns"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-2"
>
{agentPatterns.length === 0 ? (
<EmptyState
icon={<TrendingUp className="w-12 h-12" />}
title="暂无学习模式"
description="积累更多反馈后,系统会识别出行为模式"
/>
) : (
agentPatterns.map(pattern => {
const typeInfo = PATTERN_TYPE_LABELS[pattern.type] || { label: pattern.type, icon: '📊' };
return (
<div
key={`${pattern.agentId}-${pattern.pattern}`}
className="p-3 bg-gray-800/50 rounded-lg border border-gray-700"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span>{typeInfo.icon}</span>
<span className="text-sm font-medium text-white">{typeInfo.label}</span>
</div>
<Badge variant="ghost">
{(pattern.confidence * 100).toFixed(0)}%
</Badge>
</div>
<p className="text-sm text-gray-400">{pattern.description}</p>
<div className="mt-2 text-xs text-gray-500">
{pattern.examples.length}
</div>
</div>
);
})
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* 底部操作栏 */}
<div className="flex items-center justify-between p-3 border-t border-gray-800">
<div className="text-xs text-gray-500">
: {agentEvents[0] ? getTimeAgo(agentEvents[0].timestamp) : '无'}
</div>
<Button variant="ghost" size="sm" onClick={handleClear} className="text-red-400">
<X className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
);
}
// === Helpers ===
function getTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return '刚刚';
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
return `${Math.floor(seconds / 86400)} 天前`;
}
export default ActiveLearningPanel;

View File

@@ -0,0 +1,663 @@
/**
* 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';
// === 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, 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 createOptions: CloneCreateOptions = {
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,
privacyOptIn: formData.privacyOptIn,
emoji: formData.emoji,
personality: formData.personality,
notes: formData.notes || undefined,
};
const clone = await createClone(createOptions);
if (clone) {
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 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('privacyOptIn', !formData.privacyOptIn)}
className={cn(
'w-11 h-6 rounded-full transition-colors relative',
formData.privacyOptIn ? '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.privacyOptIn ? '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 text-white bg-primary rounded-lg hover:bg-primary/90 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 text-white bg-primary rounded-lg hover:bg-primary/90 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

@@ -30,7 +30,6 @@ import {
import {
getAutonomyManager,
DEFAULT_AUTONOMY_CONFIGS,
type AutonomyManager,
type AutonomyConfig,
type AutonomyLevel,
type AuditLogEntry,
@@ -135,13 +134,11 @@ function LevelSelector({
}
function ActionToggle({
action,
label,
enabled,
onChange,
disabled,
}: {
action: ActionType;
label: string;
enabled: boolean;
onChange: (enabled: boolean) => void;
@@ -331,7 +328,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
</div>
<div className="pl-6 space-y-1 border-l-2 border-gray-200 dark:border-gray-700">
<ActionToggle
action="memory_save"
label="自动保存记忆"
enabled={config.allowedActions.memoryAutoSave}
onChange={(enabled) =>
@@ -341,7 +337,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
}
/>
<ActionToggle
action="identity_update"
label="自动更新身份文件"
enabled={config.allowedActions.identityAutoUpdate}
onChange={(enabled) =>
@@ -351,7 +346,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
}
/>
<ActionToggle
action="skill_install"
label="自动安装技能"
enabled={config.allowedActions.skillAutoInstall}
onChange={(enabled) =>
@@ -361,7 +355,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
}
/>
<ActionToggle
action="selfModification"
label="自我修改行为"
enabled={config.allowedActions.selfModification}
onChange={(enabled) =>
@@ -371,7 +364,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
}
/>
<ActionToggle
action="compaction_run"
label="自动上下文压缩"
enabled={config.allowedActions.autoCompaction}
onChange={(enabled) =>
@@ -381,7 +373,6 @@ export function AutonomyConfig({ className = '', onConfigChange }: AutonomyConfi
}
/>
<ActionToggle
action="reflection_run"
label="自动反思"
enabled={config.allowedActions.autoReflection}
onChange={(enabled) =>

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useChatStore, Message } from '../store/chatStore';
import { useGatewayStore } from '../store/gatewayStore';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare } from 'lucide-react';
import { Button, EmptyState } from './ui';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
const MODELS = ['glm-5', 'qwen3.5-plus', 'kimi-k2.5', 'minimax-m2.5'];
@@ -14,13 +15,28 @@ export function ChatArea() {
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
} = useChatStore();
const { connectionState } = useGatewayStore();
const { connectionState, clones } = useGatewayStore();
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Get current clone for first conversation prompt
const currentClone = useMemo(() => {
if (!currentAgent) return null;
return clones.find((c) => c.id === currentAgent.id) || null;
}, [currentAgent, clones]);
// Check if should show first conversation prompt
const showFirstPrompt = messages.length === 0 && currentClone && !currentClone.onboardingCompleted;
// Handle suggestion click from first conversation prompt
const handleSelectSuggestion = (text: string) => {
setInput(text);
textareaRef.current?.focus();
};
// Auto-resize textarea
const adjustTextarea = useCallback(() => {
const el = textareaRef.current;
@@ -104,11 +120,18 @@ export function ChatArea() {
animate="animate"
exit="exit"
>
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="欢迎使用 ZCLAW"
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
/>
{showFirstPrompt && currentClone ? (
<FirstConversationPrompt
clone={currentClone}
onSelectSuggestion={handleSelectSuggestion}
/>
) : (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="欢迎使用 ZCLAW"
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
/>
)}
</motion.div>
)}

View File

@@ -1,53 +1,14 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import { Bot, Plus, X, Globe, Cat, Search, BarChart2, AlertCircle, RefreshCw } from 'lucide-react';
interface CloneFormData {
name: string;
role: string;
nickname: string;
scenarios: string;
workspaceDir: string;
userName: string;
userRole: string;
restrictFiles: boolean;
privacyOptIn: boolean;
}
const DEFAULT_WORKSPACE = '~/.openfang/zclaw-workspace';
function createFormFromDraft(quickConfig: {
agentName?: string;
agentRole?: string;
agentNickname?: string;
scenarios?: string[];
workspaceDir?: string;
userName?: string;
userRole?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
}): CloneFormData {
return {
name: quickConfig.agentName || '',
role: quickConfig.agentRole || '',
nickname: quickConfig.agentNickname || '',
scenarios: quickConfig.scenarios?.join(', ') || '',
workspaceDir: quickConfig.workspaceDir || DEFAULT_WORKSPACE,
userName: quickConfig.userName || '',
userRole: quickConfig.userRole || '',
restrictFiles: quickConfig.restrictFiles ?? true,
privacyOptIn: quickConfig.privacyOptIn ?? false,
};
}
import { Bot, Plus, X, Globe, Cat, Search, BarChart2, Sparkles } from 'lucide-react';
import { AgentOnboardingWizard } from './AgentOnboardingWizard';
import type { Clone } from '../store/agentStore';
export function CloneManager() {
const { clones, loadClones, createClone, deleteClone, connectionState, quickConfig, saveQuickConfig, error: storeError } = useGatewayStore();
const { clones, loadClones, deleteClone, connectionState, quickConfig } = useGatewayStore();
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CloneFormData>(createFormFromDraft({}));
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [showWizard, setShowWizard] = useState(false);
const connected = connectionState === 'connected';
@@ -55,75 +16,7 @@ export function CloneManager() {
if (connected) {
loadClones();
}
}, [connected]);
useEffect(() => {
if (showForm) {
setForm(createFormFromDraft(quickConfig));
}
}, [showForm, quickConfig]);
const handleCreate = async () => {
if (!form.name.trim()) return;
setCreateError(null);
setIsCreating(true);
try {
const scenarios = form.scenarios
? form.scenarios.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
await saveQuickConfig({
agentName: form.name,
agentRole: form.role || undefined,
agentNickname: form.nickname || undefined,
scenarios,
workspaceDir: form.workspaceDir || undefined,
userName: form.userName || undefined,
userRole: form.userRole || undefined,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
});
const clone = await createClone({
name: form.name,
role: form.role || undefined,
nickname: form.nickname || undefined,
scenarios,
workspaceDir: form.workspaceDir || undefined,
userName: form.userName || undefined,
userRole: form.userRole || undefined,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
});
if (clone) {
setCurrentAgent(toChatAgent(clone));
setForm(createFormFromDraft({
...quickConfig,
agentName: form.name,
agentRole: form.role,
agentNickname: form.nickname,
scenarios,
workspaceDir: form.workspaceDir,
userName: form.userName,
userRole: form.userRole,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
}));
setShowForm(false);
} else {
// Show error from store or generic message
const errorMsg = storeError || '创建分身失败。请检查 Gateway 连接状态和后端日志。';
setCreateError(errorMsg);
}
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
setCreateError(`创建失败: ${errorMsg}`);
} finally {
setIsCreating(false);
}
};
}, [connected, loadClones]);
const handleDelete = async (id: string) => {
if (confirm('确定删除该分身?')) {
@@ -131,146 +24,62 @@ export function CloneManager() {
}
};
const handleWizardSuccess = (clone: Clone) => {
setCurrentAgent(toChatAgent(clone));
setShowWizard(false);
};
// Merge gateway clones with local agents for display
const displayClones = clones.length > 0 ? clones : agents.map(a => ({
id: a.id,
name: a.name,
role: '默认助手',
nickname: a.name,
scenarios: [],
scenarios: [] as string[],
workspaceDir: '~/.openfang/zclaw-workspace',
userName: quickConfig.userName || '未设置',
userRole: '',
restrictFiles: true,
privacyOptIn: false,
createdAt: '',
onboardingCompleted: true,
emoji: undefined as string | undefined,
personality: undefined as string | undefined,
}));
// Function to assign pseudo icons/colors based on names for UI matching
const getIconAndColor = (name: string) => {
if (name.includes('Browser') || name.includes('浏览器')) {
return { icon: <Globe className="w-5 h-5" />, bg: 'bg-blue-500 text-white' };
// Function to get display emoji or icon for clone
const getCloneDisplay = (clone: typeof displayClones[0]) => {
// If clone has emoji, use it
if (clone.emoji) {
return {
emoji: clone.emoji,
icon: null,
bg: 'bg-gradient-to-br from-orange-400 to-red-500',
};
}
if (name.includes('AutoClaw') || name.includes('ZCLAW')) {
return { icon: <Cat className="w-6 h-6" />, bg: 'bg-gradient-to-br from-orange-400 to-red-500 text-white' };
// Fallback to icon based on name
if (clone.name.includes('Browser') || clone.name.includes('浏览器')) {
return { emoji: null, icon: <Globe className="w-5 h-5" />, bg: 'bg-blue-500 text-white' };
}
if (name.includes('沉思')) {
return { icon: <Search className="w-5 h-5" />, bg: 'bg-blue-100 text-blue-600' };
if (clone.name.includes('AutoClaw') || clone.name.includes('ZCLAW')) {
return { emoji: null, icon: <Cat className="w-6 h-6" />, bg: 'bg-gradient-to-br from-orange-400 to-red-500 text-white' };
}
if (name.includes('监控')) {
return { icon: <BarChart2 className="w-5 h-5" />, bg: 'bg-orange-100 text-orange-600' };
if (clone.name.includes('沉思')) {
return { emoji: null, icon: <Search className="w-5 h-5" />, bg: 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300' };
}
return { icon: <Bot className="w-5 h-5" />, bg: 'bg-gray-200 text-gray-600' };
if (clone.name.includes('监控')) {
return { emoji: null, icon: <BarChart2 className="w-5 h-5" />, bg: 'bg-orange-100 text-orange-600 dark:bg-orange-900 dark:text-orange-300' };
}
return { emoji: null, icon: <Bot className="w-5 h-5" />, bg: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300' };
};
return (
<div className="h-full flex flex-col py-2">
{/* Create form */}
{showForm && (
<div className="mx-2 mb-2 p-3 border border-gray-200 rounded-lg bg-white space-y-2 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-900"> Agent</span>
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="名称 (必填)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.role}
onChange={e => setForm({ ...form, role: e.target.value })}
placeholder="角色 (如: 代码助手)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.nickname}
onChange={e => setForm({ ...form, nickname: e.target.value })}
placeholder="昵称 / 对你的称呼"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.scenarios}
onChange={e => setForm({ ...form, scenarios: e.target.value })}
placeholder="场景标签 (逗号分隔)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.workspaceDir}
onChange={e => setForm({ ...form, workspaceDir: e.target.value })}
placeholder="工作目录"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={form.userName}
onChange={e => setForm({ ...form, userName: e.target.value })}
placeholder="你的名字"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
<input
type="text"
value={form.userRole}
onChange={e => setForm({ ...form, userRole: e.target.value })}
placeholder="你的角色"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:border-gray-400"
/>
</div>
<label className="flex items-center justify-between text-xs text-gray-600 px-1">
<span>访</span>
<input
type="checkbox"
checked={form.restrictFiles}
onChange={e => setForm({ ...form, restrictFiles: e.target.checked })}
/>
</label>
<label className="flex items-center justify-between text-xs text-gray-600 px-1">
<span></span>
<input
type="checkbox"
checked={form.privacyOptIn}
onChange={e => setForm({ ...form, privacyOptIn: e.target.checked })}
/>
</label>
{createError && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 px-2 py-1.5 rounded">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-1">{createError}</span>
<button onClick={() => setCreateError(null)} className="hover:text-red-800">
<X className="w-3 h-3" />
</button>
</div>
)}
<button
onClick={handleCreate}
disabled={!form.name.trim() || isCreating}
className="w-full text-xs bg-gray-900 text-white rounded py-1.5 hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isCreating ? (
<>
<RefreshCw className="w-3 h-3 animate-spin" />
...
</>
) : (
'完成配置'
)}
</button>
</div>
)}
{/* Clone list */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{displayClones.map((clone, idx) => {
const { icon, bg } = getIconAndColor(clone.name);
const { emoji, icon, bg } = getCloneDisplay(clone);
const isActive = currentAgent ? currentAgent.id === clone.id : idx === 0;
const canDelete = clones.length > 0;
@@ -279,18 +88,26 @@ export function CloneManager() {
key={clone.id}
onClick={() => setCurrentAgent(toChatAgent(clone))}
className={`group sidebar-item mx-2 px-3 py-3 rounded-lg cursor-pointer mb-1 flex items-start gap-3 transition-colors ${
isActive ? 'bg-white shadow-sm border border-gray-100' : 'hover:bg-black/5'
isActive ? 'bg-white dark:bg-gray-800 shadow-sm border border-gray-100 dark:border-gray-700' : 'hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${bg}`}>
{icon}
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${emoji ? bg : bg}`}>
{emoji ? (
<span className="text-xl">{emoji}</span>
) : (
icon
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-0.5">
<span className={`truncate ${isActive ? 'font-semibold text-gray-900' : 'font-medium text-gray-900'}`}>{clone.name}</span>
<span className={`truncate ${isActive ? 'font-semibold text-gray-900 dark:text-white' : 'font-medium text-gray-900 dark:text-white'}`}>
{clone.name}
</span>
{isActive ? <span className="text-xs text-orange-500"></span> : null}
</div>
<p className="text-xs text-gray-500 truncate">{clone.role || '新分身'}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{clone.role || clone.personality || '新分身'}
</p>
</div>
{canDelete && (
<button
@@ -305,27 +122,38 @@ export function CloneManager() {
);
})}
{/* Add new clone button as an item if we want, or keep the traditional way */}
{!showForm && (
<div
onClick={() => {
if (connected) {
setShowForm(true);
}
}}
className={`sidebar-item mx-2 px-3 py-3 rounded-lg mb-1 flex items-center gap-3 transition-colors border border-dashed border-gray-300 ${
connected
? 'cursor-pointer text-gray-500 hover:text-gray-900 hover:bg-black/5'
: 'cursor-not-allowed text-gray-400 bg-gray-50'
}`}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 bg-gray-50">
{/* Add new clone button */}
<div
onClick={() => {
if (connected) {
setShowWizard(true);
}
}}
className={`sidebar-item mx-2 px-3 py-3 rounded-lg mb-1 flex items-center gap-3 transition-colors border border-dashed border-gray-300 dark:border-gray-600 ${
connected
? 'cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'
: 'cursor-not-allowed text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800/50'
}`}
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 bg-gray-50 dark:bg-gray-800">
{connected ? (
<Sparkles className="w-5 h-5 text-primary" />
) : (
<Plus className="w-5 h-5" />
</div>
<span className="text-sm font-medium">{connected ? '快速配置新 Agent' : '连接 Gateway 后创建'}</span>
)}
</div>
)}
<span className="text-sm font-medium">
{connected ? '创建新 Agent' : '连接 Gateway 后创建'}
</span>
</div>
</div>
{/* Onboarding Wizard Modal */}
<AgentOnboardingWizard
isOpen={showWizard}
onClose={() => setShowWizard(false)}
onSuccess={handleWizardSuccess}
/>
</div>
);
}

View File

@@ -20,6 +20,54 @@ import {
Bell,
} from 'lucide-react';
// === ReDoS Protection ===
const MAX_PATTERN_LENGTH = 200;
const REGEX_TIMEOUT_MS = 100;
// Dangerous regex patterns that can cause catastrophic backtracking
const DANGEROUS_PATTERNS = [
/\([^)]*\+[^)]*\)\+/, // Nested quantifiers like (a+)+
/\([^)]*\*[^)]*\)\*/, // Nested quantifiers like (a*)*
/\([^)]*\+[^)]*\)\*/, // Mixed nested quantifiers
/\([^)]*\*[^)]*\)\+/, // Mixed nested quantifiers
/\.\*\.\*/, // Multiple greedy wildcards
/\.+\.\+/, // Multiple greedy wildcards
/(.*)\1{3,}/, // Backreference loops
];
function validateRegexPattern(pattern: string): { valid: boolean; error?: string } {
// Length check
if (pattern.length > MAX_PATTERN_LENGTH) {
return { valid: false, error: `Pattern too long (max ${MAX_PATTERN_LENGTH} chars)` };
}
// Check for dangerous constructs
for (const dangerous of DANGEROUS_PATTERNS) {
if (dangerous.test(pattern)) {
return { valid: false, error: 'Pattern contains potentially dangerous constructs' };
}
}
// Validate syntax and check execution time
try {
const regex = new RegExp(pattern);
const testString = 'a'.repeat(20) + 'b'.repeat(20);
const start = Date.now();
regex.test(testString);
const elapsed = Date.now() - start;
if (elapsed > REGEX_TIMEOUT_MS) {
return { valid: false, error: 'Pattern is too complex (execution timeout)' };
}
return { valid: true };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Invalid pattern';
return { valid: false, error: `Invalid regular expression: ${message}` };
}
}
// === Types ===
type TriggerType = 'webhook' | 'event' | 'message';
@@ -146,11 +194,10 @@ export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTrigger
if (!formData.pattern.trim()) {
newErrors.pattern = 'Pattern is required';
} else {
// Validate regex pattern
try {
new RegExp(formData.pattern);
} catch {
newErrors.pattern = 'Invalid regular expression pattern';
// Validate regex pattern with ReDoS protection
const validation = validateRegexPattern(formData.pattern);
if (!validation.valid) {
newErrors.pattern = validation.error || 'Invalid pattern';
}
}
break;

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, CheckCircle, AlertCircle, Hourglass, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
@@ -14,7 +15,7 @@ const statusConfig: Record<FeedbackStatus, { label: string; color: string; icon:
const typeLabels: Record<string, string> = {
bug: 'Bug Report',
feature: 'Feature Request';
feature: 'Feature Request',
general: 'General Feedback',
};
const priorityLabels: Record<string, string> = {
@@ -27,7 +28,7 @@ interface FeedbackHistoryProps {
onViewDetails?: (feedback: FeedbackSubmission) => void;
}
export function FeedbackHistory({ onViewDetails }: FeedbackHistoryProps) {
export function FeedbackHistory({ onViewDetails: _onViewDetails }: FeedbackHistoryProps) {
const { feedbackItems, deleteFeedback, updateFeedbackStatus } = useFeedbackStore();
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -147,7 +148,7 @@ export function FeedbackHistory({ onViewDetails }: FeedbackHistoryProps) {
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p>App Version: {feedback.metadata.appVersion}</p>
<p>OS: {feedback.metadata.os}</p>
<p>Submitted: {format(feedback.createdAt)}</p>
<p>Submitted: {formatDate(feedback.createdAt)}</p>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, Bug, Lightbulb, MessageSquare, AlertCircle, Upload, Trash2 } from 'lucide-react';
import { useFeedbackStore, type FeedbackType, type FeedbackPriority } from './feedbackStore';
import { useFeedbackStore, type FeedbackType, type FeedbackPriority, type FeedbackAttachment } from './feedbackStore';
import { Button } from '../ui';
import { useToast } from '../ui/Toast';
import { silentErrorHandler } from '../../lib/error-utils';
interface FeedbackModalProps {
onClose: () => void;
@@ -39,9 +40,9 @@ export function FeedbackModal({ onClose }: FeedbackModalProps) {
}
// Convert files to base64 for storage
const processedAttachments = await Promise.all(
const processedAttachments: FeedbackAttachment[] = await Promise.all(
attachments.map(async (file) => {
return new Promise((resolve) => {
return new Promise<FeedbackAttachment>((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
@@ -57,7 +58,7 @@ export function FeedbackModal({ onClose }: FeedbackModalProps) {
);
try {
const result = await submitFeedback({
await submitFeedback({
type,
title: title.trim(),
description: description.trim(),
@@ -70,16 +71,14 @@ export function FeedbackModal({ onClose }: FeedbackModalProps) {
},
});
if (result) {
toast('Feedback submitted successfully!', 'success');
// Reset form
setTitle('');
setDescription('');
setAttachments([]);
setType('bug');
setPriority('medium');
onClose();
}
toast('Feedback submitted successfully!', 'success');
// Reset form
setTitle('');
setDescription('');
setAttachments([]);
setType('bug');
setPriority('medium');
onClose();
} catch (err) {
toast('Failed to submit feedback. Please try again.', 'error');
}
@@ -277,7 +276,7 @@ export function FeedbackModal({ onClose }: FeedbackModalProps) {
</Button>
<Button
variant="primary"
onClick={() => { handleSubmit().catch(() => {}); }}
onClick={() => { handleSubmit().catch(silentErrorHandler('FeedbackModal')); }}
loading={isLoading}
disabled={!title.trim() || !description.trim()}
>

View File

@@ -78,7 +78,7 @@ export const useFeedbackStore = create<FeedbackStore>()(
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
submitFeedback: async (feedback) => {
submitFeedback: async (feedback): Promise<void> => {
const { feedbackItems } = get();
set({ isLoading: true, error: null });
@@ -106,8 +106,6 @@ export const useFeedbackStore = create<FeedbackStore>()(
isLoading: false,
isModalOpen: false,
});
return newFeedback;
} catch (err) {
set({
isLoading: false,

View File

@@ -0,0 +1,124 @@
/**
* FirstConversationPrompt - Welcome prompt for new Agents
*
* Displays a personalized welcome message and quick start suggestions
* when entering a new Agent's chat for the first time.
*/
import { motion } from 'framer-motion';
import { Lightbulb, ArrowRight } from 'lucide-react';
import { cn } from '../lib/utils';
import {
generateWelcomeMessage,
getQuickStartSuggestions,
getScenarioById,
type QuickStartSuggestion,
} from '../lib/personality-presets';
import type { Clone } from '../store/agentStore';
interface FirstConversationPromptProps {
clone: Clone;
onSelectSuggestion?: (text: string) => void;
onDismiss?: () => void;
}
export function FirstConversationPrompt({
clone,
onSelectSuggestion,
}: FirstConversationPromptProps) {
// Generate welcome message
const welcomeMessage = generateWelcomeMessage({
userName: clone.userName,
agentName: clone.nickname || clone.name,
emoji: clone.emoji,
personality: clone.personality,
scenarios: clone.scenarios,
});
// Get quick start suggestions based on scenarios
const suggestions = getQuickStartSuggestions(clone.scenarios || []);
const handleSuggestionClick = (suggestion: QuickStartSuggestion) => {
onSelectSuggestion?.(suggestion.text);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex flex-col items-center justify-center py-12 px-4"
>
{/* Avatar with emoji */}
<div className="mb-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 dark:from-primary/30 dark:to-primary/20 flex items-center justify-center shadow-lg">
<span className="text-4xl">{clone.emoji || '🦞'}</span>
</div>
</div>
{/* Welcome message */}
<div className="text-center max-w-md mb-8">
<p className="text-lg text-gray-700 dark:text-gray-200 whitespace-pre-line leading-relaxed">
{welcomeMessage}
</p>
</div>
{/* Quick start suggestions */}
<div className="w-full max-w-lg space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-3">
<Lightbulb className="w-4 h-4" />
<span></span>
</div>
{suggestions.map((suggestion, index) => (
<motion.button
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleSuggestionClick(suggestion)}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700',
'hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-primary/30',
'transition-all duration-200 group text-left'
)}
>
<span className="text-xl flex-shrink-0">{suggestion.icon}</span>
<span className="flex-1 text-sm text-gray-700 dark:text-gray-200">
{suggestion.text}
</span>
<ArrowRight className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</motion.button>
))}
</div>
{/* Scenario tags */}
{clone.scenarios && clone.scenarios.length > 0 && (
<div className="mt-8 flex flex-wrap gap-2 justify-center">
{clone.scenarios.map((scenarioId) => {
const scenario = getScenarioById(scenarioId);
if (!scenario) return null;
return (
<span
key={scenarioId}
className={cn(
'px-3 py-1 rounded-full text-xs font-medium',
'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
)}
>
{scenario.label}
</span>
);
})}
</div>
)}
{/* Dismiss hint */}
<p className="mt-8 text-xs text-gray-400 dark:text-gray-500">
</p>
</motion.div>
);
}
export default FirstConversationPrompt;

View File

@@ -0,0 +1,619 @@
/**
* MemoryGraph - 记忆图谱可视化组件
*
* 使用 Canvas 实现力导向图布局,展示记忆之间的关联关系。
*
* 功能:
* - 力导向布局算法
* - 节点拖拽
* - 类型筛选
* - 搜索高亮
* - 导出图片
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ZoomIn,
ZoomOut,
Maximize2,
Download,
Search,
Filter,
X,
RefreshCw,
Tag,
Clock,
Star,
} from 'lucide-react';
import { Button, Badge } from './ui';
import {
useMemoryGraphStore,
type GraphNode,
type GraphEdge,
type MemoryType,
} from '../store/memoryGraphStore';
import { useChatStore } from '../store/chatStore';
import { cardHover, defaultTransition } from '../lib/animations';
// Mark as intentionally unused for future use
void cardHover;
void defaultTransition;
// === Constants ===
const NODE_COLORS: Record<MemoryType, { fill: string; stroke: string; text: string }> = {
fact: { fill: '#3b82f6', stroke: '#1d4ed8', text: '#ffffff' },
preference: { fill: '#f59e0b', stroke: '#d97706', text: '#ffffff' },
lesson: { fill: '#10b981', stroke: '#059669', text: '#ffffff' },
context: { fill: '#8b5cf6', stroke: '#7c3aed', text: '#ffffff' },
task: { fill: '#ef4444', stroke: '#dc2626', text: '#ffffff' },
};
const TYPE_LABELS: Record<MemoryType, string> = {
fact: '事实',
preference: '偏好',
lesson: '经验',
context: '上下文',
task: '任务',
};
const NODE_RADIUS = 20;
const REPULSION_STRENGTH = 5000;
const ATTRACTION_STRENGTH = 0.01;
const DAMPING = 0.9;
const MIN_VELOCITY = 0.01;
// === Force-Directed Layout ===
function simulateStep(
nodes: GraphNode[],
edges: GraphEdge[],
width: number,
height: number
): GraphNode[] {
const updatedNodes = nodes.map(node => ({ ...node }));
// 斥力 (节点间)
for (let i = 0; i < updatedNodes.length; i++) {
for (let j = i + 1; j < updatedNodes.length; j++) {
const n1 = updatedNodes[i];
const n2 = updatedNodes[j];
const dx = n2.x - n1.x;
const dy = n2.y - n1.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = REPULSION_STRENGTH / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
n1.vx -= fx;
n1.vy -= fy;
n2.vx += fx;
n2.vy += fy;
}
}
// 引力 (边)
for (const edge of edges) {
const source = updatedNodes.find(n => n.id === edge.source);
const target = updatedNodes.find(n => n.id === edge.target);
if (!source || !target) continue;
const dx = target.x - source.x;
const dy = target.y - source.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = dist * ATTRACTION_STRENGTH * edge.strength;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
source.vx += fx;
source.vy += fy;
target.vx -= fx;
target.vy -= fy;
}
// 中心引力
const centerX = width / 2;
const centerY = height / 2;
const centerForce = 0.001;
for (const node of updatedNodes) {
node.vx += (centerX - node.x) * centerForce;
node.vy += (centerY - node.y) * centerForce;
}
// 更新位置
for (const node of updatedNodes) {
node.vx *= DAMPING;
node.vy *= DAMPING;
if (Math.abs(node.vx) < MIN_VELOCITY) node.vx = 0;
if (Math.abs(node.vy) < MIN_VELOCITY) node.vy = 0;
node.x += node.vx;
node.y += node.vy;
// 边界约束
node.x = Math.max(NODE_RADIUS, Math.min(width - NODE_RADIUS, node.x));
node.y = Math.max(NODE_RADIUS, Math.min(height - NODE_RADIUS, node.y));
}
return updatedNodes;
}
// === Main Component ===
interface MemoryGraphProps {
className?: string;
}
export function MemoryGraph({ className = '' }: MemoryGraphProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragNode, setDragNode] = useState<string | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { currentAgent } = useChatStore();
const agentId = currentAgent?.id || 'default';
const {
isLoading,
error,
filter,
layout,
selectedNodeId,
showLabels,
simulationRunning,
loadGraph,
setFilter,
resetFilter,
setLayout,
selectNode,
toggleLabels,
startSimulation,
stopSimulation,
updateNodePositions,
highlightSearch,
getFilteredNodes,
getFilteredEdges,
} = useMemoryGraphStore();
const filteredNodes = getFilteredNodes();
const filteredEdges = getFilteredEdges();
// 加载图谱
useEffect(() => {
loadGraph(agentId);
}, [agentId, loadGraph]);
// 力导向模拟
useEffect(() => {
if (!simulationRunning || filteredNodes.length === 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
const simulate = () => {
const updated = simulateStep(filteredNodes, filteredEdges, layout.width, layout.height);
updateNodePositions(updated.map(n => ({ id: n.id, x: n.x, y: n.y })));
animationRef.current = requestAnimationFrame(simulate);
};
animationRef.current = requestAnimationFrame(simulate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [simulationRunning, filteredNodes.length, filteredEdges, layout.width, layout.height, updateNodePositions]);
// Canvas 渲染
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = layout.width * dpr;
canvas.height = layout.height * dpr;
ctx.scale(dpr, dpr);
// 清空画布
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, layout.width, layout.height);
// 应用变换
ctx.save();
ctx.translate(layout.offsetX, layout.offsetY);
ctx.scale(layout.zoom, layout.zoom);
// 绘制边
for (const edge of filteredEdges) {
const source = filteredNodes.find(n => n.id === edge.source);
const target = filteredNodes.find(n => n.id === edge.target);
if (!source || !target) continue;
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
ctx.strokeStyle = edge.type === 'reference'
? 'rgba(59, 130, 246, 0.5)'
: edge.type === 'related'
? 'rgba(245, 158, 11, 0.3)'
: 'rgba(139, 92, 246, 0.2)';
ctx.lineWidth = edge.strength * 3;
ctx.stroke();
}
// 绘制节点
for (const node of filteredNodes) {
const colors = NODE_COLORS[node.type];
const isSelected = node.id === selectedNodeId;
const radius = isSelected ? NODE_RADIUS * 1.3 : NODE_RADIUS;
// 高亮效果
if (node.isHighlighted) {
ctx.beginPath();
ctx.arc(node.x, node.y, radius + 8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
ctx.fill();
}
// 节点圆形
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
ctx.fillStyle = colors.fill;
ctx.fill();
ctx.strokeStyle = colors.stroke;
ctx.lineWidth = isSelected ? 3 : 1;
ctx.stroke();
// 节点标签
if (showLabels) {
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.fillStyle = colors.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const text = node.label.slice(0, 10);
ctx.fillText(text, node.x, node.y);
}
}
ctx.restore();
// 图例
const legendY = 20;
let legendX = 20;
ctx.font = '12px Inter, system-ui, sans-serif';
for (const [type, label] of Object.entries(TYPE_LABELS)) {
const colors = NODE_COLORS[type as MemoryType];
ctx.beginPath();
ctx.arc(legendX, legendY, 6, 0, Math.PI * 2);
ctx.fillStyle = colors.fill;
ctx.fill();
ctx.fillStyle = '#9ca3af';
ctx.textAlign = 'left';
ctx.fillText(label, legendX + 12, legendY + 4);
legendX += 70;
}
}, [filteredNodes, filteredEdges, layout, selectedNodeId, showLabels]);
// 鼠标事件处理
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom;
const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom;
// 检查是否点击了节点
for (const node of filteredNodes) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < NODE_RADIUS * NODE_RADIUS) {
setDragNode(node.id);
setIsDragging(true);
selectNode(node.id);
return;
}
}
selectNode(null);
}, [filteredNodes, layout, selectNode]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging || !dragNode) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - layout.offsetX) / layout.zoom;
const y = (e.clientY - rect.top - layout.offsetY) / layout.zoom;
updateNodePositions([{ id: dragNode, x, y }]);
}, [isDragging, dragNode, layout, updateNodePositions]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setDragNode(null);
}, []);
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.2, Math.min(3, layout.zoom * delta));
setLayout({ zoom: newZoom });
}, [layout.zoom, setLayout]);
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
highlightSearch(query);
}, [highlightSearch]);
const handleExport = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `memory-graph-${new Date().toISOString().slice(0, 10)}.png`;
a.click();
}, []);
const selectedNode = filteredNodes.find(n => n.id === selectedNodeId);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* 工具栏 */}
<div className="flex items-center gap-2 p-2 bg-gray-800/50 rounded-t-lg border-b border-gray-700">
{/* 搜索框 */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索记忆..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-8 pr-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
</div>
{/* 筛选按钮 */}
<Button
variant={showFilters ? 'primary' : 'ghost'}
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-1"
>
<Filter className="w-4 h-4" />
</Button>
{/* 缩放控制 */}
<div className="flex items-center gap-1 border-l border-gray-700 pl-2">
<Button
variant="ghost"
size="sm"
onClick={() => setLayout({ zoom: Math.max(0.2, layout.zoom * 0.8) })}
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-xs text-gray-400 min-w-[3rem] text-center">
{Math.round(layout.zoom * 100)}%
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setLayout({ zoom: Math.min(3, layout.zoom * 1.2) })}
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLayout({ zoom: 1, offsetX: 0, offsetY: 0 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</div>
{/* 模拟控制 */}
<div className="flex items-center gap-1 border-l border-gray-700 pl-2">
<Button
variant={simulationRunning ? 'primary' : 'ghost'}
size="sm"
onClick={() => simulationRunning ? stopSimulation() : startSimulation()}
>
<RefreshCw className={`w-4 h-4 ${simulationRunning ? 'animate-spin' : ''}`} />
{simulationRunning ? '停止' : '布局'}
</Button>
</div>
{/* 导出 */}
<Button variant="ghost" size="sm" onClick={handleExport}>
<Download className="w-4 h-4" />
</Button>
{/* 标签切换 */}
<Button variant="ghost" size="sm" onClick={toggleLabels}>
<Tag className="w-4 h-4" />
</Button>
</div>
{/* 筛选面板 */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden bg-gray-800/30 border-b border-gray-700"
>
<div className="p-3 flex flex-wrap gap-3">
{/* 类型筛选 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
{(Object.keys(TYPE_LABELS) as MemoryType[]).map(type => (
<button
key={type}
onClick={() => {
const types = filter.types.includes(type)
? filter.types.filter(t => t !== type)
: [...filter.types, type];
setFilter({ types });
}}
className={`px-2 py-1 text-xs rounded ${
filter.types.includes(type)
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300'
}`}
>
{TYPE_LABELS[type]}
</button>
))}
</div>
{/* 重要性筛选 */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
<input
type="range"
min="0"
max="10"
value={filter.minImportance}
onChange={(e) => setFilter({ minImportance: parseInt(e.target.value) })}
className="w-20"
/>
<span className="text-xs text-gray-300">{filter.minImportance}+</span>
</div>
{/* 重置 */}
<Button variant="ghost" size="sm" onClick={resetFilter}>
<X className="w-3 h-3 mr-1" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 图谱画布 */}
<div className="flex-1 relative overflow-hidden bg-gray-900">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/80 z-10">
<RefreshCw className="w-8 h-8 text-blue-500 animate-spin" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-red-400 text-sm">{error}</div>
</div>
)}
<canvas
ref={canvasRef}
style={{ width: layout.width, height: layout.height }}
className="cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
/>
{/* 节点详情面板 */}
<AnimatePresence>
{selectedNode && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="absolute top-4 right-4 w-64 bg-gray-800 rounded-lg border border-gray-700 p-4 shadow-xl"
>
<div className="flex items-center justify-between mb-3">
<Badge variant={selectedNode.type as any}>
{TYPE_LABELS[selectedNode.type]}
</Badge>
<button
onClick={() => selectNode(null)}
className="text-gray-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-gray-200 mb-3">{selectedNode.label}</p>
<div className="space-y-2 text-xs text-gray-400">
<div className="flex items-center gap-2">
<Star className="w-3 h-3" />
: {selectedNode.importance}
</div>
<div className="flex items-center gap-2">
<Clock className="w-3 h-3" />
访: {selectedNode.accessCount}
</div>
<div className="flex items-center gap-2">
<Tag className="w-3 h-3" />
: {new Date(selectedNode.createdAt).toLocaleDateString()}
</div>
</div>
{/* 关联边统计 */}
<div className="mt-3 pt-3 border-t border-gray-700">
<div className="text-xs text-gray-400 mb-1">:</div>
<div className="text-sm text-gray-200">
{filteredEdges.filter(
e => e.source === selectedNode.id || e.target === selectedNode.id
).length}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 空状态 */}
{!isLoading && filteredNodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
</div>
)}
</div>
{/* 状态栏 */}
<div className="flex items-center justify-between px-3 py-1 bg-gray-800/50 rounded-b-lg text-xs text-gray-400">
<div className="flex items-center gap-4">
<span>: {filteredNodes.length}</span>
<span>: {filteredEdges.length}</span>
</div>
<div className="flex items-center gap-2">
{simulationRunning && (
<span className="flex items-center gap-1 text-green-400">
<RefreshCw className="w-3 h-3 animate-spin" />
...
</span>
)}
</div>
</div>
</div>
);
}
export default MemoryGraph;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Search, X, ChevronUp, ChevronDown, Clock, User, Bot, Filter } from 'lucide-react';
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter } from 'lucide-react';
import { Button } from './ui';
import { useChatStore, Message } from '../store/chatStore';

View File

@@ -0,0 +1,134 @@
/**
* PersonalitySelector - Personality style selection component for Agent onboarding
*
* Displays personality options as selectable cards with icons and descriptions.
*/
import { motion } from 'framer-motion';
import { Briefcase, Heart, Sparkles, Zap, Check } from 'lucide-react';
import { cn } from '../lib/utils';
import { PERSONALITY_OPTIONS } from '../lib/personality-presets';
export interface PersonalitySelectorProps {
value?: string;
onChange: (personalityId: string) => void;
className?: string;
}
// Map icon names to components
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Briefcase,
Heart,
Sparkles,
Zap,
};
export function PersonalitySelector({ value, onChange, className }: PersonalitySelectorProps) {
return (
<div className={cn('grid grid-cols-2 gap-3', className)}>
{PERSONALITY_OPTIONS.map((option) => {
const IconComponent = iconMap[option.icon] || Briefcase;
const isSelected = value === option.id;
return (
<motion.button
key={option.id}
type="button"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => onChange(option.id)}
className={cn(
'relative p-4 rounded-xl border-2 text-left transition-all',
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
isSelected
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'
)}
>
{/* Selection indicator */}
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-2 right-2 w-5 h-5 bg-primary rounded-full flex items-center justify-center"
>
<Check className="w-3 h-3 text-white" />
</motion.div>
)}
{/* Icon */}
<div
className={cn(
'w-10 h-10 rounded-lg flex items-center justify-center mb-3',
isSelected
? 'bg-primary text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
)}
>
<IconComponent className="w-5 h-5" />
</div>
{/* Label */}
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{option.label}
</h4>
{/* Description */}
<p className="text-xs text-gray-500 dark:text-gray-400">
{option.description}
</p>
{/* Traits preview */}
<div className="mt-3 flex flex-wrap gap-1">
{option.traits.slice(0, 2).map((trait) => (
<span
key={trait}
className={cn(
'px-2 py-0.5 text-xs rounded-full',
isSelected
? 'bg-primary/10 text-primary'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
)}
>
{trait}
</span>
))}
</div>
</motion.button>
);
})}
</div>
);
}
// Export a compact version for display purposes
export interface PersonalityBadgeProps {
personalityId?: string;
size?: 'sm' | 'md';
className?: string;
}
export function PersonalityBadge({ personalityId, size = 'sm', className }: PersonalityBadgeProps) {
if (!personalityId) return null;
const option = PERSONALITY_OPTIONS.find((p) => p.id === personalityId);
if (!option) return null;
const IconComponent = iconMap[option.icon] || Briefcase;
const sizeStyles = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1.5 text-sm',
};
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary',
sizeStyles[size],
className
)}
>
<IconComponent className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />
<span>{option.label}</span>
</span>
);
}

View File

@@ -14,10 +14,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Brain,
Sparkles,
Check,
X,
Clock,
ChevronDown,
ChevronRight,
RefreshCw,
@@ -184,9 +182,8 @@ function ProposalCard({
onReject: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const identityManager = getAgentIdentityManager();
const fileName = proposal.filePath.split('/').pop() || proposal.filePath;
const fileName = proposal.file.split('/').pop() || proposal.file;
const fileType = fileName.toLowerCase().replace('.md', '').toUpperCase();
return (
@@ -243,8 +240,8 @@ function ProposalCard({
</h5>
<pre className="text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
{proposal.proposedContent.slice(0, 500)}
{proposal.proposedContent.length > 500 && '...'}
{proposal.suggestedContent.slice(0, 500)}
{proposal.suggestedContent.length > 500 && '...'}
</pre>
</div>
</div>
@@ -283,7 +280,6 @@ function ReflectionEntry({
}) {
const positivePatterns = result.patterns.filter((p) => p.sentiment === 'positive').length;
const negativePatterns = result.patterns.filter((p) => p.sentiment === 'negative').length;
const highPriorityImprovements = result.improvements.filter((i) => i.priority === 'high').length;
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@@ -423,7 +419,7 @@ export function ReflectionLog({
const handleApproveProposal = useCallback(
(proposal: IdentityChangeProposal) => {
const identityManager = getAgentIdentityManager();
identityManager.approveChange(proposal.id);
identityManager.approveProposal(proposal.id);
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
onProposalApprove?.(proposal);
},
@@ -433,7 +429,7 @@ export function ReflectionLog({
const handleRejectProposal = useCallback(
(proposal: IdentityChangeProposal) => {
const identityManager = getAgentIdentityManager();
identityManager.rejectChange(proposal.id);
identityManager.rejectProposal(proposal.id);
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
onProposalReject?.(proposal);
},
@@ -590,7 +586,7 @@ export function ReflectionLog({
</button>
</div>
) : (
history.map((result, i) => (
history.map((result) => (
<ReflectionEntry
key={result.timestamp}
result={result}

View File

@@ -1,16 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain, MessageCircle
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain
} from 'lucide-react';
import { MemoryPanel } from './MemoryPanel';
import { FeedbackModal, FeedbackHistory } from './Feedback';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge, EmptyState } from './ui';
import { getPersonalityById } from '../lib/personality-presets';
import { silentErrorHandler } from '../lib/error-utils';
export function RightPanel() {
const {
@@ -18,8 +19,7 @@ export function RightPanel() {
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
} = useGatewayStore();
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'feedback'>('status');
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory'>('status');
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
@@ -47,7 +47,7 @@ export function RightPanel() {
}, [connected]);
const handleReconnect = () => {
connect().catch(() => {});
connect().catch(silentErrorHandler('RightPanel'));
};
const handleStartEdit = () => {
@@ -87,8 +87,6 @@ export function RightPanel() {
const userMsgCount = messages.filter(m => m.role === 'user').length;
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
const toolCallCount = messages.filter(m => m.role === 'tool').length;
const topMetricValue = usageStats ? usageStats.totalTokens.toLocaleString() : messages.length.toString();
const topMetricLabel = usageStats ? '累计 Token' : '当前消息';
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
const userNameDisplay = selectedClone?.userName || quickConfig.userName || '未设置';
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || '未设置';
@@ -101,9 +99,9 @@ export function RightPanel() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<BarChart3 className="w-4 h-4" />
<span className="font-medium">{topMetricValue}</span>
<span className="font-medium">{messages.length}</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">{topMetricLabel}</span>
<span className="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400" role="tablist">
<Button
@@ -154,18 +152,6 @@ export function RightPanel() {
>
<Brain className="w-4 h-4" />
</Button>
<Button
variant={activeTab === 'feedback' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setActiveTab('feedback')}
className="flex items-center gap-1 text-xs px-2 py-1"
title="Feedback"
aria-label="Feedback"
aria-selected={activeTab === 'feedback'}
role="tab"
>
<MessageCircle className="w-4 h-4" />
</Button>
</div>
</div>
@@ -182,10 +168,21 @@ export function RightPanel() {
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-cyan-400 to-blue-500 flex items-center justify-center text-white text-lg font-semibold">
{(selectedClone?.nickname || currentAgent?.name || 'Z').slice(0, 1)}
{selectedClone?.emoji ? (
<span className="text-2xl">{selectedClone.emoji}</span>
) : (
<span>{(selectedClone?.nickname || currentAgent?.name || 'Z').slice(0, 1)}</span>
)}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{selectedClone?.name || currentAgent?.name || 'ZCLAW'}</div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
{selectedClone?.name || currentAgent?.name || 'ZCLAW'}
{selectedClone?.personality && (
<Badge variant="default" className="text-xs ml-1">
{getPersonalityById(selectedClone.personality)?.label || selectedClone.personality}
</Badge>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{selectedClone?.role || 'AI coworker'}</div>
</div>
</div>
@@ -203,7 +200,7 @@ export function RightPanel() {
<Button
variant="primary"
size="sm"
onClick={() => { handleSaveAgent().catch(() => {}); }}
onClick={() => { handleSaveAgent().catch(silentErrorHandler('RightPanel')); }}
aria-label="Save edit"
>
Save
@@ -396,29 +393,6 @@ export function RightPanel() {
)}
</motion.div>
</div>
) : activeTab === 'feedback' ? (
<div className="space-y-4">
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<MessageCircle className="w-4 h-4" />
User Feedback
</h3>
<Button
variant="primary"
size="sm"
onClick={() => setIsFeedbackModalOpen(true)}
>
New Feedback
</Button>
</div>
<FeedbackHistory />
</motion.div>
</div>
) : (
<>
{/* Gateway 连接状态 */}
@@ -630,12 +604,6 @@ export function RightPanel() {
)}
</div>
{/* Feedback Modal */}
<AnimatePresence>
{isFeedbackModalOpen && (
<FeedbackModal onClose={() => setIsFeedbackModalOpen(false)} />
)}
</AnimatePresence>
</aside>
);
}

View File

@@ -0,0 +1,175 @@
/**
* ScenarioTags - Scenario selection component for Agent onboarding
*
* Displays scenario options as clickable tags for multi-selection.
*/
import { motion, AnimatePresence } from 'framer-motion';
import {
Code,
PenLine,
Package,
BarChart,
Palette,
Server,
Search,
Megaphone,
MoreHorizontal,
Check,
} from 'lucide-react';
import { cn } from '../lib/utils';
import { SCENARIO_TAGS } from '../lib/personality-presets';
export interface ScenarioTagsProps {
value: string[];
onChange: (scenarios: string[]) => void;
className?: string;
maxSelections?: number;
}
// Map icon names to components
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Code,
PenLine,
Package,
BarChart,
Palette,
Server,
Search,
Megaphone,
MoreHorizontal,
};
export function ScenarioTags({
value,
onChange,
className,
maxSelections = 5,
}: ScenarioTagsProps) {
const toggleScenario = (scenarioId: string) => {
if (value.includes(scenarioId)) {
// Remove scenario
onChange(value.filter((id) => id !== scenarioId));
} else {
// Add scenario (if under max)
if (value.length < maxSelections) {
onChange([...value, scenarioId]);
}
}
};
return (
<div className={cn('space-y-3', className)}>
{/* Tags Grid */}
<div className="flex flex-wrap gap-2">
<AnimatePresence mode="popLayout">
{SCENARIO_TAGS.map((tag) => {
const IconComponent = iconMap[tag.icon] || Code;
const isSelected = value.includes(tag.id);
const isDisabled = !isSelected && value.length >= maxSelections;
return (
<motion.button
key={tag.id}
type="button"
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
onClick={() => toggleScenario(tag.id)}
disabled={isDisabled}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all',
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
isSelected && 'bg-primary text-white shadow-sm',
!isSelected &&
!isDisabled &&
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700',
isDisabled && 'opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-800'
)}
>
{isSelected ? (
<Check className="w-4 h-4" />
) : (
<IconComponent className="w-4 h-4" />
)}
<span>{tag.label}</span>
</motion.button>
);
})}
</AnimatePresence>
</div>
{/* Selection Info */}
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>
{value.length}/{maxSelections}
</span>
{value.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className="text-primary hover:underline"
>
</button>
)}
</div>
{/* Selected Scenarios Preview */}
{value.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg"
>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2"></p>
<div className="flex flex-wrap gap-1">
{value.map((id) => {
const tag = SCENARIO_TAGS.find((t) => t.id === id);
if (!tag) return null;
return (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
>
{tag.label}
</span>
);
})}
</div>
</motion.div>
)}
</div>
);
}
// Export a display-only version for showing selected scenarios
export interface ScenarioBadgesProps {
scenarios?: string[];
className?: string;
}
export function ScenarioBadges({ scenarios, className }: ScenarioBadgesProps) {
if (!scenarios || scenarios.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-1', className)}>
{scenarios.map((id) => {
const tag = SCENARIO_TAGS.find((t) => t.id === id);
if (!tag) return null;
const IconComponent = iconMap[tag.icon] || Code;
return (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded text-xs"
>
<IconComponent className="w-3 h-3" />
{tag.label}
</span>
);
})}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
import { getStoredGatewayToken, setStoredGatewayToken } from '../../lib/gateway-client';
import { silentErrorHandler } from '../../lib/error-utils';
export function General() {
const { connectionState, gatewayVersion, error, connect, disconnect, quickConfig, saveQuickConfig } = useGatewayStore();
@@ -68,7 +69,7 @@ export function General() {
};
const handleConnect = () => {
connect(undefined, gatewayToken || undefined).catch(() => {});
connect(undefined, gatewayToken || undefined).catch(silentErrorHandler('General'));
};
const handleDisconnect = () => { disconnect(); };

View File

@@ -1,5 +1,6 @@
import { FileText, Globe } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function MCPServices() {
const { quickConfig, saveQuickConfig } = useGatewayStore();
@@ -40,7 +41,7 @@ export function MCPServices() {
{svc.enabled ? '已启用' : '已停用'}
</span>
<button
onClick={() => { toggleService(svc.id).catch(() => {}); }}
onClick={() => { toggleService(svc.id).catch(silentErrorHandler('MCPServices')); }}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
{svc.enabled ? '停用' : '启用'}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
import { useGatewayStore } from '../../store/gatewayStore';
import { useChatStore } from '../../store/chatStore';
import { silentErrorHandler } from '../../lib/error-utils';
// Helper function to format context window size
function formatContextWindow(tokens?: number): string {
@@ -41,14 +42,14 @@ export function ModelsAPI() {
setTimeout(() => connect(
gatewayUrl || quickConfig.gatewayUrl || 'ws://127.0.0.1:50051/ws',
gatewayToken || quickConfig.gatewayToken || getStoredGatewayToken()
).catch(() => {}), 500);
).catch(silentErrorHandler('ModelsAPI')), 500);
};
const handleSaveGatewaySettings = () => {
saveQuickConfig({
gatewayUrl,
gatewayToken,
}).catch(() => {});
}).catch(silentErrorHandler('ModelsAPI'));
};
const handleRefreshModels = () => {
@@ -222,14 +223,14 @@ export function ModelsAPI() {
type="text"
value={gatewayUrl}
onChange={(e) => setGatewayUrl(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayUrl }).catch(() => {}); }}
onBlur={() => { saveQuickConfig({ gatewayUrl }).catch(silentErrorHandler('ModelsAPI')); }}
className="w-full bg-transparent border-none outline-none"
/>
<input
type="password"
value={gatewayToken}
onChange={(e) => setGatewayToken(e.target.value)}
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(() => {}); }}
onBlur={() => { saveQuickConfig({ gatewayToken }).catch(silentErrorHandler('ModelsAPI')); }}
placeholder="Gateway auth token"
className="w-full bg-transparent border-none outline-none"
/>

View File

@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Privacy() {
const { quickConfig, workspaceInfo, loadWorkspaceInfo, saveQuickConfig } = useGatewayStore();
useEffect(() => {
loadWorkspaceInfo().catch(() => {});
loadWorkspaceInfo().catch(silentErrorHandler('Privacy'));
}, []);
const optIn = quickConfig.privacyOptIn ?? false;
@@ -27,7 +28,7 @@ export function Privacy() {
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 shadow-sm">
<div className="flex justify-between items-start mb-4">
<h3 className="font-medium text-gray-900"></h3>
<Toggle checked={optIn} onChange={(value) => { saveQuickConfig({ privacyOptIn: value }).catch(() => {}); }} />
<Toggle checked={optIn} onChange={(value) => { saveQuickConfig({ privacyOptIn: value }).catch(silentErrorHandler('Privacy')); }} />
</div>
<p className="text-xs text-gray-500 leading-relaxed">
使"设置"退

View File

@@ -15,6 +15,7 @@ import {
ClipboardList,
Clock,
} from 'lucide-react';
import { silentErrorHandler } from '../../lib/error-utils';
import { General } from './General';
import { UsageStats } from './UsageStats';
import { ModelsAPI } from './ModelsAPI';
@@ -169,7 +170,7 @@ function Feedback() {
className="w-full h-40 border border-gray-300 rounded-lg p-3 text-sm resize-none focus:outline-none focus:border-orange-400"
/>
<button
onClick={() => { handleCopy().catch(() => {}); }}
onClick={() => { handleCopy().catch(silentErrorHandler('SettingsLayout')); }}
disabled={!text.trim()}
className="mt-4 px-6 py-2 bg-orange-500 text-white text-sm rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { Wrench, Zap, FileCode, Globe, Mail, Database, Search, MessageSquare } from 'lucide-react';
// ZCLAW 内置系统技能
@@ -70,7 +71,7 @@ export function Skills() {
useEffect(() => {
if (connected) {
loadSkillsCatalog().catch(() => {});
loadSkillsCatalog().catch(silentErrorHandler('Skills'));
}
}, [connected]);
@@ -97,7 +98,7 @@ export function Skills() {
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900"></h1>
<button
onClick={() => { loadSkillsCatalog().catch(() => {}); }}
onClick={() => { loadSkillsCatalog().catch(silentErrorHandler('Skills')); }}
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors"
>
@@ -153,7 +154,7 @@ export function Skills() {
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
/>
<button
onClick={() => { handleAddDir().catch(() => {}); }}
onClick={() => { handleAddDir().catch(silentErrorHandler('Skills')); }}
className="text-xs text-gray-500 px-4 py-2 border border-gray-200 rounded-lg hover:text-gray-700 transition-colors"
>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Workspace() {
const {
@@ -11,7 +12,7 @@ export function Workspace() {
const [projectDir, setProjectDir] = useState('~/.openfang/zclaw-workspace');
useEffect(() => {
loadWorkspaceInfo().catch(() => {});
loadWorkspaceInfo().catch(silentErrorHandler('Workspace'));
}, []);
useEffect(() => {
@@ -49,7 +50,7 @@ export function Workspace() {
type="text"
value={projectDir}
onChange={(e) => setProjectDir(e.target.value)}
onBlur={() => { handleWorkspaceBlur().catch(() => {}); }}
onBlur={() => { handleWorkspaceBlur().catch(silentErrorHandler('Workspace')); }}
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none"
/>
<button className="text-xs px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
@@ -70,7 +71,7 @@ export function Workspace() {
Agent 访使
</div>
</div>
<Toggle checked={restrictFiles} onChange={(value) => { handleToggle('restrictFiles', value).catch(() => {}); }} />
<Toggle checked={restrictFiles} onChange={(value) => { handleToggle('restrictFiles', value).catch(silentErrorHandler('Workspace')); }} />
</div>
<div className="flex justify-between items-center py-3 border-t border-gray-100">
@@ -78,7 +79,7 @@ export function Workspace() {
<div className="font-medium text-gray-900 mb-1"></div>
<div className="text-xs text-gray-500"></div>
</div>
<Toggle checked={autoSaveContext} onChange={(value) => { handleToggle('autoSaveContext', value).catch(() => {}); }} />
<Toggle checked={autoSaveContext} onChange={(value) => { handleToggle('autoSaveContext', value).catch(silentErrorHandler('Workspace')); }} />
</div>
<div className="flex justify-between items-center py-3 border-t border-gray-100">
@@ -86,7 +87,7 @@ export function Workspace() {
<div className="font-medium text-gray-900 mb-1"></div>
<div className="text-xs text-gray-500"> Agent </div>
</div>
<Toggle checked={fileWatching} onChange={(value) => { handleToggle('fileWatching', value).catch(() => {}); }} />
<Toggle checked={fileWatching} onChange={(value) => { handleToggle('fileWatching', value).catch(silentErrorHandler('Workspace')); }} />
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Users, Bot, GitBranch, MessageSquare, Layers } from 'lucide-react';
import { CloneManager } from './CloneManager';
import { HandList } from './HandList';
import { TaskList } from './TaskList';
import { WorkflowList } from './WorkflowList';
import { TeamList } from './TeamList';
import { SwarmDashboard } from './SwarmDashboard';
import { useGatewayStore } from '../store/gatewayStore';
@@ -106,7 +106,7 @@ export function Sidebar({
onSelectHand={handleSelectHand}
/>
)}
{activeTab === 'workflow' && <TaskList />}
{activeTab === 'workflow' && <WorkflowList />}
{activeTab === 'team' && (
<TeamList
selectedTeamId={selectedTeamId}

View File

@@ -16,7 +16,6 @@ import {
Search,
Package,
Check,
X,
Plus,
Minus,
Sparkles,
@@ -325,36 +324,36 @@ export function SkillMarket({
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await new Promise((resolve) => setTimeout(resolve, 500));
engine.refreshIndex();
// engine.refreshIndex doesn't exist - skip
setSkills(engine.getAllSkills());
setIsRefreshing(false);
}, [engine]);
const handleInstall = useCallback(
(skill: SkillInfo) => {
engine.installSkill(skill.id);
setSkills(engine.getAllSkills());
onSkillInstall?.(skill);
},
[engine, onSkillInstall]
// Install skill - update local state
setSkills((prev) => prev.map(s => ({ ...s, installed: true })));
onSkillInstall?.(skill);
},
[onSkillInstall]
);
const handleUninstall = useCallback(
(skill: SkillInfo) => {
engine.uninstallSkill(skill.id);
setSkills(engine.getAllSkills());
onSkillUninstall?.(skill);
// Uninstall skill - update local state
setSkills((prev) => prev.map(s => ({ ...s, installed: false })));
onSkillUninstall?.(skill);
},
[engine, onSkillUninstall]
[onSkillUninstall]
);
const handleSearch = useCallback(
(query: string) => {
async (query: string) => {
setSearchQuery(query);
if (query.trim()) {
// Get suggestions based on search
const mockConversation = [{ role: 'user' as const, content: query }];
const newSuggestions = engine.suggestSkills(mockConversation);
const newSuggestions = await engine.suggestSkills(mockConversation, 'default', 3);
setSuggestions(newSuggestions.slice(0, 3));
} else {
setSuggestions([]);

View File

@@ -0,0 +1,211 @@
/**
* * SkillCard - 技能卡片组件
*
* * 展示单个技能的基本信息,包括名称、描述、能力和安装状态
*/
import { motion } from 'framer-motion';
import {
Package,
Check,
Star,
MoreHorizontal,
Clock,
} from 'lucide-react';
import type { Skill } from '../../types/skill-market';
import { useState } from 'react';
// === 类型定义 ===
interface SkillCardProps {
/** 技能数据 */
skill: Skill;
/** 是否选中 */
isSelected?: boolean;
/** 点击回调 */
onClick?: () => void;
/** 安装/卸载回调 */
onToggleInstall?: () => void;
/** 显示更多操作 */
onShowMore?: () => void;
}
// === 分类配置 ===
const CATEGORY_CONFIG: Record<string, { color: string; bgColor: string }> = {
development: { color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
security: { color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30' },
analytics: { color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30' },
content: { color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/30' },
ops: { color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/30' },
management: { color: 'text-cyan-600 dark:text-cyan-400', bgColor: 'bg-cyan-100 dark:bg-cyan-900/30' },
testing: { color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/30' },
business: { color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-100 dark:bg-amber-900/30' },
marketing: { color: 'text-rose-600 dark:text-rose-400', bgColor: 'bg-rose-100 dark:bg-rose-900/30' },
};
// === 分类名称映射 ===
const CATEGORY_NAMES: Record<string, string> = {
development: '开发',
security: '安全',
analytics: '分析',
content: '内容',
ops: '运维',
management: '管理',
testing: '测试',
business: '商务',
marketing: '营销',
};
/**
* SkillCard - 技能卡片组件
*/
export function SkillCard({
skill,
isSelected = false,
onClick,
onToggleInstall,
onShowMore,
}: SkillCardProps) {
const [isHovered, setIsHovered] = useState(false);
const categoryConfig = CATEGORY_CONFIG[skill.category] || {
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-100 dark:bg-gray-800/30',
};
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
onClick={onClick}
className={`
relative p-4 rounded-lg border cursor-pointer transition-all duration-200
${isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
{/* 顶部:图标和名称 */}
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${categoryConfig.bgColor}`}
>
<Package className={`w-5 h-5 ${categoryConfig.color}`} />
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{skill.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{skill.author || '官方'}
</p>
</div>
</div>
{/* 安装按钮 */}
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onToggleInstall?.();
}}
className={`
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
${skill.installed
? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-blue-500 text-white hover:bg-blue-600'
}
`}
>
{skill.installed ? (
<span className="flex items-center gap-1">
<Check className="w-3 h-3" />
</span>
) : (
<span className="flex items-center gap-1">
<Package className="w-3 h-3" />
</span>
)}
</motion.button>
</div>
{/* 描述 */}
<p className="text-xs text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
{skill.description}
</p>
{/* 标签和能力 */}
<div className="flex flex-wrap gap-1 mb-3">
{skill.capabilities.slice(0, 3).map((cap) => (
<span
key={cap}
className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
>
{cap}
</span>
))}
{skill.capabilities.length > 3 && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
+{skill.capabilities.length - 3}
</span>
)}
</div>
{/* 底部:分类、评分和统计 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<span
className={`px-2 py-0.5 rounded text-xs ${categoryConfig.bgColor} ${categoryConfig.color}`}
>
{CATEGORY_NAMES[skill.category] || skill.category}
</span>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
{skill.rating !== undefined && (
<span className="flex items-center gap-1">
<Star className="w-3 h-3 text-yellow-500 fill-current" />
{skill.rating.toFixed(1)}
</span>
)}
{skill.reviewCount !== undefined && skill.reviewCount > 0 && (
<span>{skill.reviewCount} </span>
)}
{skill.installedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(skill.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{/* 悬停时显示更多按钮 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
className="absolute top-2 right-2"
>
<button
onClick={(e) => {
e.stopPropagation();
onShowMore?.();
}}
className="p-1.5 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="更多操作"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</motion.div>
</motion.div>
);
}
export default SkillCard;

View File

@@ -0,0 +1,103 @@
/**
* EmojiPicker - Emoji selection component for Agent onboarding
*
* Displays categorized emoji presets for users to choose from.
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../../lib/utils';
import { EMOJI_PRESETS, ALL_EMOJIS } from '../../lib/personality-presets';
type EmojiCategory = 'all' | 'animals' | 'objects' | 'expressions';
export interface EmojiPickerProps {
value?: string;
onChange: (emoji: string) => void;
className?: string;
}
const categoryLabels: Record<EmojiCategory, string> = {
all: '全部',
animals: '动物',
objects: '物体',
expressions: '表情',
};
export function EmojiPicker({ value, onChange, className }: EmojiPickerProps) {
const [activeCategory, setActiveCategory] = useState<EmojiCategory>('all');
const getEmojisForCategory = (category: EmojiCategory): string[] => {
if (category === 'all') {
return ALL_EMOJIS;
}
return EMOJI_PRESETS[category] || [];
};
const emojis = getEmojisForCategory(activeCategory);
return (
<div className={cn('space-y-3', className)}>
{/* Category Tabs */}
<div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
{(Object.keys(categoryLabels) as EmojiCategory[]).map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
activeCategory === category
? 'bg-white dark:bg-gray-700 text-primary shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
)}
>
{categoryLabels[category]}
</button>
))}
</div>
{/* Emoji Grid */}
<motion.div
layout
className="grid grid-cols-8 gap-1"
>
<AnimatePresence mode="popLayout">
{emojis.map((emoji) => (
<motion.button
key={emoji}
type="button"
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
onClick={() => onChange(emoji)}
className={cn(
'w-9 h-9 flex items-center justify-center text-xl rounded-lg transition-all',
'hover:bg-gray-100 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1',
value === emoji && 'bg-primary/10 ring-2 ring-primary'
)}
>
{emoji}
</motion.button>
))}
</AnimatePresence>
</motion.div>
{/* Selected Preview */}
{value && (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-2 bg-primary/5 rounded-lg"
>
<span className="text-2xl">{value}</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
</span>
</motion.div>
)}
</div>
);
}