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

@@ -13,6 +13,7 @@ import { useGatewayStore } from './store/gatewayStore';
import { useTeamStore } from './store/teamStore';
import { getStoredGatewayToken } from './lib/gateway-client';
import { pageVariants, defaultTransition, fadeInVariants } from './lib/animations';
import { silentErrorHandler } from './lib/error-utils';
import { Bot, Users } from 'lucide-react';
import { EmptyState } from './components/ui';
@@ -33,7 +34,7 @@ function App() {
useEffect(() => {
if (connectionState === 'disconnected') {
const gatewayToken = getStoredGatewayToken();
connect(undefined, gatewayToken).catch(() => {});
connect(undefined, gatewayToken).catch(silentErrorHandler('App'));
}
}, [connect, connectionState]);

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>
);
}

View File

@@ -17,7 +17,6 @@ import {
canAutoExecute,
executeWithAutonomy,
DEFAULT_AUTONOMY_CONFIGS,
type ActionType,
type AutonomyLevel,
} from '../autonomy-manager';

View File

@@ -10,22 +10,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ReflectionEngine,
DEFAULT_REFLECTION_CONFIG,
type ReflectionConfig,
} from '../reflection-engine';
import {
ContextCompactor,
DEFAULT_COMPACTION_CONFIG,
type CompactionConfig,
} from '../context-compactor';
import {
MemoryExtractor,
DEFAULT_EXTRACTION_CONFIG,
type ExtractionConfig,
} from '../memory-extractor';
import {
getLLMAdapter,
resetLLMAdapter,
type LLMProvider,
} from '../llm-service';

View File

@@ -0,0 +1,354 @@
/**
* 主动学习引擎 - 从用户交互中学习并改进 Agent 行为
*
* 提供学习事件记录、模式提取和建议生成功能。
* Phase 1: 内存存储Zustand 持久化
* Phase 2: SQLite + 向量化存储
*/
import {
type LearningEvent,
type LearningPattern,
type LearningSuggestion,
type LearningEventType,
type FeedbackSentiment,
} from '../types/active-learning';
// === 常量 ===
const MAX_EVENTS = 1000;
const PATTERN_CONFIDENCE_THRESHOLD = 0.7;
const SUGGESTION_COOLDOWN_HOURS = 2;
// === 生成 ID ===
function generateEventId(): string {
return `le-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// === 分析反馈情感 ===
export function analyzeSentiment(text: string): FeedbackSentiment {
const positive = ['好的', '很棒', '谢谢', '完美', 'excellent', '喜欢', '爱了', 'good', 'great', 'nice', '满意'];
const negative = ['不好', '差', '糟糕', '错误', 'wrong', 'bad', '不喜欢', '讨厌', '问题', '失败', 'fail', 'error'];
const lowerText = text.toLowerCase();
if (positive.some(w => lowerText.includes(w.toLowerCase()))) return 'positive';
if (negative.some(w => lowerText.includes(w.toLowerCase()))) return 'negative';
return 'neutral';
}
// === 分析学习类型 ===
export function analyzeEventType(text: string): LearningEventType {
const lowerText = text.toLowerCase();
if (lowerText.includes('纠正') || lowerText.includes('不对') || lowerText.includes('修改')) {
return 'correction';
}
if (lowerText.includes('喜欢') || lowerText.includes('偏好') || lowerText.includes('风格')) {
return 'preference';
}
if (lowerText.includes('场景') || lowerText.includes('上下文') || lowerText.includes('情况')) {
return 'context';
}
if (lowerText.includes('总是') || lowerText.includes('经常') || lowerText.includes('习惯')) {
return 'behavior';
}
return 'implicit';
}
// === 推断偏好 ===
export function inferPreference(feedback: string, sentiment: FeedbackSentiment): string {
if (sentiment === 'positive') {
if (feedback.includes('简洁')) return '用户偏好简洁的回复';
if (feedback.includes('详细')) return '用户偏好详细的回复';
if (feedback.includes('快速')) return '用户偏好快速响应';
return '用户对当前回复风格满意';
}
if (sentiment === 'negative') {
if (feedback.includes('太长')) return '用户偏好更短的回复';
if (feedback.includes('太短')) return '用户偏好更详细的回复';
if (feedback.includes('不准确')) return '用户偏好更准确的信息';
return '用户对当前回复风格不满意';
}
return '用户反馈中性';
}
// === 学习引擎类 ===
export class ActiveLearningEngine {
private events: LearningEvent[] = [];
private patterns: LearningPattern[] = [];
private suggestions: LearningSuggestion[] = [];
private initialized: boolean = false;
constructor() {
this.initialized = true;
}
/**
* 记录学习事件
*/
recordEvent(
event: Omit<LearningEvent, 'id' | 'timestamp' | 'acknowledged' | 'appliedCount'>
): LearningEvent {
// 检查重复事件
const existing = this.events.find(e =>
e.agentId === event.agentId &&
e.messageId === event.messageId &&
e.type === event.type
);
if (existing) {
// 更新现有事件
existing.observation += ' | ' + event.observation;
existing.confidence = (existing.confidence + event.confidence) / 2;
existing.appliedCount++;
return existing;
}
// 创建新事件
const newEvent: LearningEvent = {
...event,
id: generateEventId(),
timestamp: Date.now(),
acknowledged: false,
appliedCount: 0,
};
this.events.push(newEvent);
this.extractPatterns(newEvent);
// 保持事件数量限制
if (this.events.length > MAX_EVENTS) {
this.events = this.events.slice(-MAX_EVENTS);
}
return newEvent;
}
/**
* 从反馈中学习
*/
learnFromFeedback(
agentId: string,
messageId: string,
feedback: string,
context?: string
): LearningEvent {
const sentiment = analyzeSentiment(feedback);
const type = analyzeEventType(feedback);
return this.recordEvent({
type,
agentId,
messageId,
trigger: context || 'User feedback',
observation: feedback,
context,
inferredPreference: inferPreference(feedback, sentiment),
confidence: sentiment === 'positive' ? 0.8 : sentiment === 'negative' ? 0.5 : 0.3,
});
}
/**
* 提取学习模式
*/
private extractPatterns(event: LearningEvent): void {
// 1. 正面反馈 -> 偏好正面回复
if (event.observation.includes('谢谢') || event.observation.includes('好的')) {
this.addPattern({
type: 'preference',
pattern: 'positive_response_preference',
description: '用户偏好正面回复风格',
examples: [event.observation],
confidence: 0.8,
agentId: event.agentId,
});
}
// 2. 纠正 -> 需要更精确
if (event.type === 'correction') {
this.addPattern({
type: 'rule',
pattern: 'precision_preference',
description: '用户对精确性有更高要求',
examples: [event.observation],
confidence: 0.9,
agentId: event.agentId,
});
}
// 3. 上下文相关 -> 场景偏好
if (event.context) {
this.addPattern({
type: 'context',
pattern: 'context_aware',
description: 'Agent 需要关注上下文',
examples: [event.context],
confidence: 0.6,
agentId: event.agentId,
});
}
}
/**
* 添加学习模式
*/
private addPattern(pattern: Omit<LearningPattern, 'updatedAt'>): void {
const existing = this.patterns.find(p =>
p.type === pattern.type &&
p.pattern === pattern.pattern &&
p.agentId === pattern.agentId
);
if (existing) {
// 增强置信度
existing.confidence = Math.min(1, existing.confidence + pattern.confidence * 0.1);
existing.examples.push(pattern.examples[0]);
existing.updatedAt = Date.now();
} else {
this.patterns.push({
...pattern,
updatedAt: Date.now(),
});
}
}
/**
* 生成学习建议
*/
generateSuggestions(agentId: string): LearningSuggestion[] {
const suggestions: LearningSuggestion[] = [];
const now = Date.now();
// 获取该 Agent 的模式
const agentPatterns = this.patterns.filter(p => p.agentId === agentId);
for (const pattern of agentPatterns) {
// 检查冷却时间
const hoursSinceUpdate = (now - (pattern.updatedAt || now)) / (1000 * 60 * 60);
if (hoursSinceUpdate < SUGGESTION_COOLDOWN_HOURS) continue;
// 检查置信度阈值
if (pattern.confidence < PATTERN_CONFIDENCE_THRESHOLD) continue;
// 生成建议
suggestions.push({
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2)}`,
agentId,
type: pattern.type,
pattern: pattern.pattern,
suggestion: this.generateSuggestionContent(pattern),
confidence: pattern.confidence,
createdAt: now,
expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000),
dismissed: false,
});
}
return suggestions;
}
/**
* 生成建议内容
*/
private generateSuggestionContent(pattern: LearningPattern): string {
const templates: Record<string, string> = {
positive_response_preference:
'用户似乎偏好正面回复。建议在回复时保持积极和确认的语气。',
precision_preference:
'用户对精确性有更高要求。建议在提供信息时更加详细和准确。',
context_aware:
'Agent 需要关注上下文。建议在回复时考虑对话的背景和历史。',
};
return templates[pattern.pattern] || `观察到模式: ${pattern.pattern}`;
}
/**
* 获取统计信息
*/
getStats(agentId: string) {
const agentEvents = this.events.filter(e => e.agentId === agentId);
const agentPatterns = this.patterns.filter(p => p.agentId === agentId);
const eventsByType: Record<LearningEventType, number> = {
preference: 0,
correction: 0,
context: 0,
feedback: 0,
behavior: 0,
implicit: 0,
};
for (const event of agentEvents) {
eventsByType[event.type]++;
}
return {
totalEvents: agentEvents.length,
eventsByType,
totalPatterns: agentPatterns.length,
avgConfidence: agentPatterns.length > 0
? agentPatterns.reduce((sum, p) => sum + p.confidence, 0) / agentPatterns.length
: 0,
};
}
/**
* 获取所有事件
*/
getEvents(agentId?: string): LearningEvent[] {
if (agentId) {
return this.events.filter(e => e.agentId === agentId);
}
return [...this.events];
}
/**
* 获取所有模式
*/
getPatterns(agentId?: string): LearningPattern[] {
if (agentId) {
return this.patterns.filter(p => p.agentId === agentId);
}
return [...this.patterns];
}
/**
* 确认事件
*/
acknowledgeEvent(eventId: string): void {
const event = this.events.find(e => e.id === eventId);
if (event) {
event.acknowledged = true;
}
}
/**
* 清除事件
*/
clearEvents(agentId: string): void {
this.events = this.events.filter(e => e.agentId !== agentId);
this.patterns = this.patterns.filter(p => p.agentId !== agentId);
}
}
// === 单例实例 ===
let engineInstance: ActiveLearningEngine | null = null;
export function getActiveLearningEngine(): ActiveLearningEngine {
if (!engineInstance) {
engineInstance = new ActiveLearningEngine();
}
return engineInstance;
}
export function resetActiveLearningEngine(): void {
engineInstance = null;
}

View File

@@ -8,7 +8,7 @@
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.1
*/
import { MemoryIndex, getMemoryIndex, resetMemoryIndex, tokenize } from './memory-index';
import { MemoryIndex, getMemoryIndex, tokenize } from './memory-index';
// === Types ===
@@ -36,6 +36,7 @@ export interface MemorySearchOptions {
tags?: string[];
limit?: number;
minImportance?: number;
[key: string]: unknown;
}
export interface MemoryStats {

View File

@@ -0,0 +1,460 @@
/**
* Browser Automation Client for ZCLAW
* Provides TypeScript API for Fantoccini-based browser automation
*/
import { invoke } from '@tauri-apps/api/core';
// ============================================================================
// Types
// ============================================================================
export interface BrowserSessionResult {
session_id: string;
}
export interface BrowserSessionInfo {
id: string;
name: string;
current_url: string | null;
title: string | null;
status: string;
created_at: string;
last_activity: string;
}
export interface BrowserNavigationResult {
url: string | null;
title: string | null;
}
export interface BrowserElementInfo {
selector: string;
tag_name: string | null;
text: string | null;
is_displayed: boolean;
is_enabled: boolean;
is_selected: boolean;
location: BrowserElementLocation | null;
size: BrowserElementSize | null;
}
export interface BrowserElementLocation {
x: number;
y: number;
}
export interface BrowserElementSize {
width: number;
height: number;
}
export interface BrowserScreenshotResult {
base64: string;
format: string;
}
export interface FormFieldData {
selector: string;
value: string;
}
// ============================================================================
// Session Management
// ============================================================================
/**
* Create a new browser session
*/
export async function createSession(options?: {
webdriverUrl?: string;
headless?: boolean;
browserType?: 'chrome' | 'firefox' | 'edge' | 'safari';
windowWidth?: number;
windowHeight?: number;
}): Promise<BrowserSessionResult> {
return invoke('browser_create_session', {
webdriverUrl: options?.webdriverUrl,
headless: options?.headless,
browserType: options?.browserType,
windowWidth: options?.windowWidth,
windowHeight: options?.windowHeight,
});
}
/**
* Close a browser session
*/
export async function closeSession(sessionId: string): Promise<void> {
return invoke('browser_close_session', { sessionId });
}
/**
* List all browser sessions
*/
export async function listSessions(): Promise<BrowserSessionInfo[]> {
return invoke('browser_list_sessions');
}
/**
* Get session info
*/
export async function getSession(sessionId: string): Promise<BrowserSessionInfo> {
return invoke('browser_get_session', { sessionId });
}
// ============================================================================
// Navigation
// ============================================================================
/**
* Navigate to URL
*/
export async function navigate(
sessionId: string,
url: string
): Promise<BrowserNavigationResult> {
return invoke('browser_navigate', { sessionId, url });
}
/**
* Go back
*/
export async function back(sessionId: string): Promise<void> {
return invoke('browser_back', { sessionId });
}
/**
* Go forward
*/
export async function forward(sessionId: string): Promise<void> {
return invoke('browser_forward', { sessionId });
}
/**
* Refresh page
*/
export async function refresh(sessionId: string): Promise<void> {
return invoke('browser_refresh', { sessionId });
}
/**
* Get current URL
*/
export async function getCurrentUrl(sessionId: string): Promise<string> {
return invoke('browser_get_url', { sessionId });
}
/**
* Get page title
*/
export async function getTitle(sessionId: string): Promise<string> {
return invoke('browser_get_title', { sessionId });
}
// ============================================================================
// Element Interaction
// ============================================================================
/**
* Find element by CSS selector
*/
export async function findElement(
sessionId: string,
selector: string
): Promise<BrowserElementInfo> {
return invoke('browser_find_element', { sessionId, selector });
}
/**
* Find multiple elements
*/
export async function findElements(
sessionId: string,
selector: string
): Promise<BrowserElementInfo[]> {
return invoke('browser_find_elements', { sessionId, selector });
}
/**
* Click element
*/
export async function click(sessionId: string, selector: string): Promise<void> {
return invoke('browser_click', { sessionId, selector });
}
/**
* Type text into element
*/
export async function typeText(
sessionId: string,
selector: string,
text: string,
clearFirst?: boolean
): Promise<void> {
return invoke('browser_type', { sessionId, selector, text, clearFirst });
}
/**
* Get element text
*/
export async function getText(sessionId: string, selector: string): Promise<string> {
return invoke('browser_get_text', { sessionId, selector });
}
/**
* Get element attribute
*/
export async function getAttribute(
sessionId: string,
selector: string,
attribute: string
): Promise<string | null> {
return invoke('browser_get_attribute', { sessionId, selector, attribute });
}
/**
* Wait for element
*/
export async function waitForElement(
sessionId: string,
selector: string,
timeoutMs?: number
): Promise<BrowserElementInfo> {
return invoke('browser_wait_for_element', {
sessionId,
selector,
timeoutMs: timeoutMs ?? 10000,
});
}
// ============================================================================
// Advanced Operations
// ============================================================================
/**
* Execute JavaScript
*/
export async function executeScript(
sessionId: string,
script: string,
args?: unknown[]
): Promise<unknown> {
return invoke('browser_execute_script', { sessionId, script, args });
}
/**
* Take screenshot
*/
export async function screenshot(sessionId: string): Promise<BrowserScreenshotResult> {
return invoke('browser_screenshot', { sessionId });
}
/**
* Take element screenshot
*/
export async function elementScreenshot(
sessionId: string,
selector: string
): Promise<BrowserScreenshotResult> {
return invoke('browser_element_screenshot', { sessionId, selector });
}
/**
* Get page source
*/
export async function getSource(sessionId: string): Promise<string> {
return invoke('browser_get_source', { sessionId });
}
// ============================================================================
// High-Level Tasks
// ============================================================================
/**
* Scrape page content
*/
export async function scrapePage(
sessionId: string,
selectors: string[],
waitFor?: string,
timeoutMs?: number
): Promise<Record<string, string[]>> {
return invoke('browser_scrape_page', {
sessionId,
selectors,
waitFor,
timeoutMs,
});
}
/**
* Fill form
*/
export async function fillForm(
sessionId: string,
fields: FormFieldData[],
submitSelector?: string
): Promise<void> {
return invoke('browser_fill_form', { sessionId, fields, submitSelector });
}
// ============================================================================
// Browser Client Class (Convenience Wrapper)
// ============================================================================
/**
* High-level browser client for easier usage
*/
export class Browser {
private sessionId: string | null = null;
/**
* Start a new browser session
*/
async start(options?: {
webdriverUrl?: string;
headless?: boolean;
browserType?: 'chrome' | 'firefox' | 'edge' | 'safari';
windowWidth?: number;
windowHeight?: number;
}): Promise<string> {
const result = await createSession(options);
this.sessionId = result.session_id;
return this.sessionId;
}
/**
* Close browser session
*/
async close(): Promise<void> {
if (this.sessionId) {
await closeSession(this.sessionId);
this.sessionId = null;
}
}
/**
* Get current session ID
*/
getSessionId(): string | null {
return this.sessionId;
}
/**
* Navigate to URL
*/
async goto(url: string): Promise<BrowserNavigationResult> {
this.ensureSession();
return navigate(this.sessionId!, url);
}
/**
* Find element
*/
async $(selector: string): Promise<BrowserElementInfo> {
this.ensureSession();
return findElement(this.sessionId!, selector);
}
/**
* Find multiple elements
*/
async $$(selector: string): Promise<BrowserElementInfo[]> {
this.ensureSession();
return findElements(this.sessionId!, selector);
}
/**
* Click element
*/
async click(selector: string): Promise<void> {
this.ensureSession();
return click(this.sessionId!, selector);
}
/**
* Type text
*/
async type(selector: string, text: string, clearFirst = false): Promise<void> {
this.ensureSession();
return typeText(this.sessionId!, selector, text, clearFirst);
}
/**
* Wait for element
*/
async wait(selector: string, timeoutMs = 10000): Promise<BrowserElementInfo> {
this.ensureSession();
return waitForElement(this.sessionId!, selector, timeoutMs);
}
/**
* Take screenshot
*/
async screenshot(): Promise<BrowserScreenshotResult> {
this.ensureSession();
return screenshot(this.sessionId!);
}
/**
* Execute JavaScript
*/
async eval(script: string, args?: unknown[]): Promise<unknown> {
this.ensureSession();
return executeScript(this.sessionId!, script, args);
}
/**
* Get page source
*/
async source(): Promise<string> {
this.ensureSession();
return getSource(this.sessionId!);
}
/**
* Get current URL
*/
async url(): Promise<string> {
this.ensureSession();
return getCurrentUrl(this.sessionId!);
}
/**
* Get page title
*/
async title(): Promise<string> {
this.ensureSession();
return getTitle(this.sessionId!);
}
/**
* Scrape page content
*/
async scrape(
selectors: string[],
waitFor?: string,
timeoutMs?: number
): Promise<Record<string, string[]>> {
this.ensureSession();
return scrapePage(this.sessionId!, selectors, waitFor, timeoutMs);
}
/**
* Fill form
*/
async fillForm(fields: FormFieldData[], submitSelector?: string): Promise<void> {
this.ensureSession();
return fillForm(this.sessionId!, fields, submitSelector);
}
private ensureSession(): void {
if (!this.sessionId) {
throw new Error('Browser session not started. Call start() first.');
}
}
}
// Default export
export default Browser;

View File

@@ -12,13 +12,15 @@ import {
ErrorSeverity,
} from './error-types';
// === Error Store ===
// === Types ===
interface StoredError extends AppError {
export interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}
// === Error Store ===
interface ErrorStore {
errors: StoredError[];
addError: (error: AppError) => void;
@@ -52,12 +54,17 @@ function initErrorStore(): void {
errors: [],
addError: (error: AppError) => {
errorStore.errors = [error, ...errorStore.errors];
const storedError: StoredError = {
...error,
dismissed: false,
reported: false,
};
errorStore.errors = [storedError, ...errorStore.errors];
// Notify listeners
notifyErrorListeners(error);
},
dismissError: (id: string) => void {
dismissError(id: string): void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
@@ -66,11 +73,11 @@ function initErrorStore(): void {
}
},
dismissAll: () => void {
dismissAll(): void {
errorStore.errors = errorStore.errors.map(e => ({ ...e, dismissed: true }));
},
markReported: (id: string) => void {
markReported(id: string): void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
@@ -79,19 +86,19 @@ function initErrorStore(): void {
}
},
getUndismissedErrors: () => StoredError[] => {
getUndismissedErrors(): StoredError[] {
return errorStore.errors.filter(e => !e.dismissed);
},
getErrorCount: () => number => {
getErrorCount(): number {
return errorStore.errors.filter(e => !e.dismissed).length;
},
getErrorsByCategory: (category: ErrorCategory) => StoredError[] => {
getErrorsByCategory(category: ErrorCategory): StoredError[] {
return errorStore.errors.filter(e => e.category === category && !e.dismissed);
},
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[] => {
getErrorsBySeverity(severity: ErrorSeverity): StoredError[] {
return errorStore.errors.filter(e => e.severity === severity && !e.dismissed);
},
};
@@ -366,8 +373,3 @@ interface ErrorEvent {
reason?: string;
message?: string;
}
export interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}

View File

@@ -353,13 +353,15 @@ export function classifyError(error: unknown): AppError {
severity: pattern.severity,
title: pattern.title,
message: pattern.messageTemplate(match),
// Only include name and message, not stack trace (security)
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
? `${error.name}: ${error.message}`
: String(error),
recoverable: pattern.recoverable,
recoverySteps: pattern.recoverySteps,
timestamp: new Date(),
originalError: error,
// Only preserve original error in development mode
originalError: import.meta.env.DEV ? error : undefined,
};
}
@@ -370,8 +372,9 @@ export function classifyError(error: unknown): AppError {
severity: 'medium',
title: 'An Error Occurred',
message: error instanceof Error ? error.message : 'An unexpected error occurred.',
// Only include name and message, not stack trace (security)
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
? `${error.name}: ${error.message}`
: String(error),
recoverable: true,
recoverySteps: [
@@ -380,7 +383,8 @@ export function classifyError(error: unknown): AppError {
{ description: 'Contact support with the error details' },
],
timestamp: new Date(),
originalError: error,
// Only preserve original error in development mode
originalError: import.meta.env.DEV ? error : undefined,
};
}

View File

@@ -0,0 +1,82 @@
/**
* 错误处理工具函数
* 提供统一的错误消息提取和静默错误处理
*/
/**
* 从未知错误中提取错误消息
* @param err - 捕获的错误
* @returns 格式化的错误消息字符串
*/
export function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (err && typeof err === 'object' && 'message' in err) {
return String((err as { message: unknown }).message);
}
return 'Unknown error';
}
/**
* 类型守卫:检查是否为 Error 实例
*/
export function isError(err: unknown): err is Error {
return err instanceof Error;
}
/**
* 获取错误的堆栈跟踪(仅开发环境)
*/
export function getErrorStack(err: unknown): string | undefined {
if (import.meta.env.DEV && err instanceof Error) {
return err.stack;
}
return undefined;
}
/**
* 创建静默错误处理器
* 用于 UI 事件处理器中预期的、不需要用户通知的错误
* 在开发环境中会记录警告,生产环境中静默处理
*
* @param context - 上下文名称,用于日志标识
* @returns 错误处理函数
*
* @example
* // 在事件处理器中使用
* onClick={() => { handleSubmit().catch(silentErrorHandler('FeedbackModal')); }}
*/
export function silentErrorHandler(context: string): (err: unknown) => void {
return (err: unknown) => {
if (import.meta.env.DEV) {
console.warn(`[${context}] Operation failed silently:`, getErrorMessage(err));
}
};
}
/**
* 安全执行异步操作,捕获错误并可选地记录
* 用于不阻塞主流程的副作用操作
*
* @param context - 上下文名称
* @param fn - 要执行的异步函数
* @param options - 配置选项
*
* @example
* // 安全执行连接操作
* safeAsync('App', () => connect());
*/
export async function safeAsync<T>(
context: string,
fn: () => Promise<T>,
options: { logInDev?: boolean } = { logInDev: true }
): Promise<T | undefined> {
try {
return await fn();
} catch (err: unknown) {
if (options.logInDev !== false && import.meta.env.DEV) {
console.warn(`[${context}] Async operation failed:`, getErrorMessage(err));
}
return undefined;
}
}

View File

@@ -37,15 +37,31 @@ import {
/**
* Whether to use WSS (WebSocket Secure) instead of WS.
* Set VITE_USE_WSS=true in production environments.
* - Production: defaults to WSS for security
* - Development: defaults to WS for convenience
* - Override: set VITE_USE_WSS=false to force WS in production
*/
const USE_WSS = import.meta.env.VITE_USE_WSS === 'true';
const USE_WSS = import.meta.env.VITE_USE_WSS !== 'false' && import.meta.env.PROD;
/**
* Default protocol based on WSS configuration.
*/
const DEFAULT_WS_PROTOCOL = USE_WSS ? 'wss://' : 'ws://';
/**
* Check if a URL points to localhost.
*/
function isLocalhost(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '[::1]';
} catch {
return false;
}
}
// OpenFang endpoints (actual port is 50051, not 4200)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
@@ -87,7 +103,12 @@ export interface GatewayEvent {
seq?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
export interface GatewayPong {
type: 'pong';
timestamp?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent | GatewayPong;
function createIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -119,7 +140,7 @@ export interface AgentStreamDelta {
/** OpenFang WebSocket stream event types */
export interface OpenFangStreamEvent {
type: 'text_delta' | 'phase' | 'response' | 'typing' | 'tool_call' | 'tool_result' | 'hand' | 'workflow' | 'error';
type: 'text_delta' | 'phase' | 'response' | 'typing' | 'tool_call' | 'tool_result' | 'hand' | 'workflow' | 'error' | 'connected' | 'agents_updated';
content?: string;
phase?: 'streaming' | 'done';
state?: 'start' | 'stop';
@@ -136,6 +157,8 @@ export interface OpenFangStreamEvent {
workflow_result?: unknown;
message?: string;
code?: string;
agent_id?: string;
agents?: Array<{ id: string; name: string; status: string }>;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
@@ -481,6 +504,11 @@ export class GatewayClient {
return this.connectRest();
}
// Security warning: non-localhost with insecure WebSocket
if (!this.url.startsWith('wss://') && !isLocalhost(this.url)) {
console.warn('[Gateway] Connecting to non-localhost with insecure WebSocket (ws://). Consider using WSS in production.');
}
this.autoReconnect = true;
this.setState('connecting');
@@ -945,8 +973,57 @@ export class GatewayClient {
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}): Promise<any> {
return this.restPost('/api/agents', opts);
// Build manifest_toml for OpenClaw Gateway
const lines: string[] = [];
lines.push(`name = "${opts.nickname || opts.name}"`);
lines.push(`model_provider = "bailian"`);
lines.push(`model_name = "${opts.model || 'qwen3.5-plus'}"`);
// Add identity section
lines.push('');
lines.push('[identity]');
if (opts.emoji) {
lines.push(`emoji = "${opts.emoji}"`);
}
if (opts.personality) {
lines.push(`personality = "${opts.personality}"`);
}
if (opts.communicationStyle) {
lines.push(`communication_style = "${opts.communicationStyle}"`);
}
// Add scenarios
if (opts.scenarios && opts.scenarios.length > 0) {
lines.push('');
lines.push('scenarios = [');
opts.scenarios.forEach((s, i) => {
lines.push(` "${s}"${i < opts.scenarios!.length - 1 ? ',' : ''}`);
});
lines.push(']');
}
// Add user context
if (opts.userName || opts.userRole) {
lines.push('');
lines.push('[user_context]');
if (opts.userName) {
lines.push(`name = "${opts.userName}"`);
}
if (opts.userRole) {
lines.push(`role = "${opts.userRole}"`);
}
}
const manifestToml = lines.join('\n');
return this.restPost('/api/agents', {
manifest_toml: manifestToml,
});
}
async updateClone(id: string, updates: Record<string, any>): Promise<any> {
return this.restPut(`/api/agents/${id}`, updates);
@@ -1496,7 +1573,9 @@ export class GatewayClient {
/** Subscribe to agent stream events */
onAgentStream(callback: (delta: AgentStreamDelta) => void): () => void {
return this.on('agent', callback);
return this.on('agent', (payload: unknown) => {
callback(payload as AgentStreamDelta);
});
}
// === Internal ===
@@ -1518,7 +1597,8 @@ export class GatewayClient {
private handleEvent(event: GatewayEvent, connectResolve?: () => void, connectReject?: (error: Error) => void) {
// Handle connect challenge
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
this.performHandshake(event.payload?.nonce, connectResolve, connectReject);
const payload = event.payload as { nonce?: string } | undefined;
this.performHandshake(payload?.nonce || '', connectResolve, connectReject);
return;
}
@@ -1526,7 +1606,12 @@ export class GatewayClient {
this.emitEvent(event.event, event.payload);
}
private async performHandshake(challengeNonce: string, connectResolve?: () => void, connectReject?: (error: Error) => void) {
private async performHandshake(challengeNonce: string | undefined, connectResolve?: () => void, connectReject?: (error: Error) => void) {
if (!challengeNonce) {
this.log('error', 'No challenge nonce received');
connectReject?.(new Error('Handshake failed: no challenge nonce'));
return;
}
const connectId = `connect_${Date.now()}`;
// Use a valid client ID from GATEWAY_CLIENT_ID_SET
// Valid IDs: gateway-client, cli, webchat, node-host, test
@@ -1761,7 +1846,7 @@ export class GatewayClient {
// Don't reconnect immediately, let the next heartbeat check
}, GatewayClient.HEARTBEAT_TIMEOUT);
} catch (error) {
this.log('error', 'Failed to send heartbeat', error);
this.log('error', `Failed to send heartbeat: ${error instanceof Error ? error.message : String(error)}`);
}
}

View File

@@ -187,8 +187,13 @@ class OpenAILLMAdapter implements LLMServiceAdapter {
});
if (!response.ok) {
const error = await response.text();
throw new Error(`[OpenAI] API error: ${response.status} - ${error}`);
const errorBody = await response.text();
// Log full error in development only
if (import.meta.env.DEV) {
console.error('[OpenAI] API error:', errorBody);
}
// Return sanitized error to caller
throw new Error(`[OpenAI] API error: ${response.status} - Request failed`);
}
const data = await response.json();
@@ -247,8 +252,13 @@ class VolcengineLLMAdapter implements LLMServiceAdapter {
});
if (!response.ok) {
const error = await response.text();
throw new Error(`[Volcengine] API error: ${response.status} - ${error}`);
const errorBody = await response.text();
// Log full error in development only
if (import.meta.env.DEV) {
console.error('[Volcengine] API error:', errorBody);
}
// Return sanitized error to caller
throw new Error(`[Volcengine] API error: ${response.status} - Request failed`);
}
const data = await response.json();

View File

@@ -7,8 +7,12 @@
* @module message-virtualization
*/
import { useRef, useCallback, useMemo, useEffect, type React } from 'react';
import { VariableSizeList as List } from 'react-window';
import { useRef, useCallback, useMemo, useEffect, type CSSProperties, type ReactNode } from 'react';
import React from 'react';
import { VariableSizeList } from 'react-window';
// Type alias for convenience
type List = VariableSizeList;
/**
* Message item interface for virtualization
@@ -24,7 +28,7 @@ export interface VirtualizedMessageItem {
*/
export interface VirtualizedMessageListProps {
messages: VirtualizedMessageItem[];
renderMessage: (id: string, style: React.CSSProperties) => React.ReactNode;
renderMessage: (id: string, style: CSSProperties) => ReactNode;
height: number;
width: number | string;
overscan?: number;
@@ -49,7 +53,7 @@ const DEFAULT_HEIGHTS: Record<string, number> = {
*/
export interface UseVirtualizedMessagesReturn {
/** Reference to the VariableSizeList instance */
listRef: React.RefObject<List | null>;
listRef: React.RefObject<VariableSizeList | null>;
/** Get the current height for a message by id and role */
getHeight: (id: string, role: string) => number;
/** Update the measured height for a message */
@@ -388,7 +392,7 @@ export function useMemoizedContent<T>(
cache?: MessageCache<T>
): T {
// Use provided cache or create a default one
const cacheRef = useRef<MessageCache<T>>();
const cacheRef = useRef<MessageCache<T> | undefined>(undefined);
if (!cacheRef.current && !cache) {
cacheRef.current = new MessageCache<T>(200);
}

View File

@@ -0,0 +1,361 @@
/**
* Personality Presets Configuration
*
* Defines personality styles, scenario tags, and emoji presets for Agent onboarding.
* Used by AgentOnboardingWizard to provide guided personality setup.
*/
// === Personality Options ===
export interface PersonalityOption {
id: string;
label: string;
description: string;
icon: string; // Icon name for Lucide
traits: string[];
communicationStyle: string;
}
export const PERSONALITY_OPTIONS: PersonalityOption[] = [
{
id: 'professional',
label: '专业严谨',
description: '精确、可靠、技术导向',
icon: 'Briefcase',
traits: ['精确', '可靠', '技术导向', '系统化'],
communicationStyle: '专业、准确、注重细节,提供技术深度和可操作的建议',
},
{
id: 'friendly',
label: '友好亲切',
description: '温暖、耐心、易于沟通',
icon: 'Heart',
traits: ['温暖', '耐心', '易于沟通', '善解人意'],
communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念',
},
{
id: 'creative',
label: '创意灵活',
description: '想象力丰富、善于探索',
icon: 'Sparkles',
traits: ['想象力丰富', '善于探索', '思维开放', '创新'],
communicationStyle: '富有创意、思维开放,鼓励探索新想法和解决方案',
},
{
id: 'concise',
label: '简洁高效',
description: '快速、直接、结果导向',
icon: 'Zap',
traits: ['快速', '直接', '结果导向', '高效'],
communicationStyle: '简洁明了、直奔主题,专注于快速解决问题',
},
];
// === Scenario Tags ===
export interface ScenarioTag {
id: string;
label: string;
description: string;
icon: string; // Icon name for Lucide
keywords: string[];
}
export const SCENARIO_TAGS: ScenarioTag[] = [
{
id: 'coding',
label: '编程开发',
description: '代码编写、调试、代码审查',
icon: 'Code',
keywords: ['编程', '代码', '开发', '调试', 'Bug', '重构'],
},
{
id: 'writing',
label: '内容写作',
description: '文章撰写、文案创作、编辑润色',
icon: 'PenLine',
keywords: ['写作', '文案', '文章', '内容', '编辑', '润色'],
},
{
id: 'product',
label: '产品策划',
description: '产品规划、需求分析、用户研究',
icon: 'Package',
keywords: ['产品', '需求', '用户', '规划', '功能', 'PRD'],
},
{
id: 'data',
label: '数据分析',
description: '数据处理、统计分析、可视化',
icon: 'BarChart',
keywords: ['数据', '分析', '统计', '图表', '可视化', '报表'],
},
{
id: 'design',
label: '设计创意',
description: 'UI/UX设计、视觉设计、原型制作',
icon: 'Palette',
keywords: ['设计', 'UI', 'UX', '视觉', '原型', '界面'],
},
{
id: 'devops',
label: '运维部署',
description: '系统运维、CI/CD、容器化部署',
icon: 'Server',
keywords: ['运维', '部署', 'CI/CD', 'Docker', 'K8s', '服务器'],
},
{
id: 'research',
label: '研究调研',
description: '技术调研、文献研究、竞品分析',
icon: 'Search',
keywords: ['研究', '调研', '分析', '文献', '竞品', '技术'],
},
{
id: 'marketing',
label: '营销推广',
description: '营销策略、内容营销、社媒运营',
icon: 'Megaphone',
keywords: ['营销', '推广', '运营', '社媒', '增长', '转化'],
},
{
id: 'other',
label: '其他',
description: '其他用途或综合场景',
icon: 'MoreHorizontal',
keywords: [],
},
];
// === Emoji Presets ===
export const EMOJI_PRESETS = {
animals: ['🦞', '🐱', '🐶', '🦊', '🐼', '🦁', '🐬', '🦄'],
objects: ['💻', '🚀', '⚡', '🔧', '📚', '🎨', '⭐', '💎'],
expressions: ['😊', '🤓', '😎', '🤖'],
};
export const ALL_EMOJIS = [
...EMOJI_PRESETS.animals,
...EMOJI_PRESETS.objects,
...EMOJI_PRESETS.expressions,
];
// === Quick Start Suggestions ===
export interface QuickStartSuggestion {
icon: string;
text: string;
scenarios: string[]; // Which scenarios this suggestion applies to
}
export const QUICK_START_SUGGESTIONS: QuickStartSuggestion[] = [
{
icon: '💡',
text: '帮我写一个 Python 脚本处理 Excel 文件',
scenarios: ['coding', 'data'],
},
{
icon: '📊',
text: '分析这个数据集的趋势和关键指标',
scenarios: ['data', 'research'],
},
{
icon: '✍️',
text: '帮我起草一份产品需求文档',
scenarios: ['product', 'writing'],
},
{
icon: '🔍',
text: '帮我研究一下这个技术方案的可行性',
scenarios: ['research', 'coding'],
},
{
icon: '🎨',
text: '给我一些 UI 设计的创意建议',
scenarios: ['design'],
},
{
icon: '📝',
text: '帮我写一篇技术博客文章',
scenarios: ['writing'],
},
{
icon: '🚀',
text: '帮我规划一个完整的营销方案',
scenarios: ['marketing', 'product'],
},
{
icon: '⚙️',
text: '帮我配置一个自动化部署流程',
scenarios: ['devops', 'coding'],
},
];
// === Helper Functions ===
/**
* Get personality option by ID
*/
export function getPersonalityById(id: string): PersonalityOption | undefined {
return PERSONALITY_OPTIONS.find((p) => p.id === id);
}
/**
* Get scenario tag by ID
*/
export function getScenarioById(id: string): ScenarioTag | undefined {
return SCENARIO_TAGS.find((s) => s.id === id);
}
/**
* Get quick start suggestions for given scenarios
*/
export function getQuickStartSuggestions(scenarios: string[]): QuickStartSuggestion[] {
if (!scenarios || scenarios.length === 0) {
// Return first 3 general suggestions if no scenarios selected
return QUICK_START_SUGGESTIONS.slice(0, 3);
}
// Filter suggestions that match any of the selected scenarios
const matching = QUICK_START_SUGGESTIONS.filter((s) =>
s.scenarios.some((scenario) => scenarios.includes(scenario))
);
// Return up to 3 matching suggestions, fallback to first 3 if none match
return matching.length > 0 ? matching.slice(0, 3) : QUICK_START_SUGGESTIONS.slice(0, 3);
}
/**
* Generate welcome message based on personality and scenarios
*/
export function generateWelcomeMessage(config: {
userName?: string;
agentName: string;
emoji?: string;
personality?: string;
scenarios?: string[];
}): string {
const { userName, agentName, emoji, personality, scenarios } = config;
// Build greeting
let greeting = '';
if (userName) {
greeting = `你好,${userName}`;
} else {
greeting = '你好!';
}
// Build introduction
let intro = `我是${emoji ? ' ' + emoji : ''} ${agentName}`;
// Add scenario context
if (scenarios && scenarios.length > 0) {
const scenarioLabels = scenarios
.map((id) => getScenarioById(id)?.label)
.filter(Boolean)
.slice(0, 3);
if (scenarioLabels.length > 0) {
intro += `,你的${scenarioLabels.join('、')}助手`;
}
}
// Add personality touch
if (personality) {
const personalityOption = getPersonalityById(personality);
if (personalityOption) {
intro += `。我会以${personalityOption.traits[0]}的方式为你提供帮助`;
}
}
// Add closing
intro += '。有什么我可以帮你的吗?';
return `${greeting}\n\n${intro}`;
}
/**
* Generate SOUL.md content based on personality config
*/
export function generateSoulContent(config: {
agentName: string;
emoji?: string;
personality?: string;
scenarios?: string[];
communicationStyle?: string;
}): string {
const { agentName, emoji, personality, scenarios, communicationStyle } = config;
const personalityOption = personality ? getPersonalityById(personality) : undefined;
const scenarioLabels =
scenarios
?.map((id) => getScenarioById(id)?.label)
.filter(Boolean)
.join('、') || '通用';
return `# ${agentName} 人格
> ${emoji || '🤖'} ${agentName} - ${scenarioLabels}助手
## 核心特质
${
personalityOption
? personalityOption.traits.map((t) => `- ${t}`).join('\n')
: '- 高效执行\n- 专业可靠\n- 主动服务'
}
## 沟通风格
${communicationStyle || personalityOption?.communicationStyle || '简洁、专业、友好'}
## 专业领域
${scenarioLabels}
## 边界
- 安全约束:不执行可能损害用户或系统的操作
- 隐私保护:不主动收集或分享敏感信息
- 能力边界:超出能力范围时坦诚告知
## 语气
- 使用中文进行交流
- 保持专业但友好的态度
- 适时提供额外上下文和建议
`;
}
/**
* Generate USER.md content based on user profile
*/
export function generateUserContent(config: {
userName?: string;
userRole?: string;
scenarios?: string[];
}): string {
const { userName, userRole, scenarios } = config;
const scenarioLabels =
scenarios
?.map((id) => getScenarioById(id)?.label)
.filter(Boolean)
.join('、') || '通用';
const sections: string[] = ['# 用户档案\n'];
if (userName) {
sections.push(`## 基本信息\n\n- 姓名:${userName}`);
if (userRole) {
sections.push(`- 角色:${userRole}`);
}
sections.push('');
}
sections.push(`## 关注领域\n\n${scenarioLabels}\n`);
sections.push(`## 偏好设置\n\n- 语言:中文\n- 沟通风格:直接、高效\n`);
return sections.join('\n');
}

View File

@@ -11,9 +11,8 @@
*/
import { getVikingClient, type VikingHttpClient } from './viking-client';
import { getMemoryManager, type MemoryType } from './agent-memory';
import { getMemoryExtractor } from './memory-extractor';
import { canAutoExecute, executeWithAutonomy } from './autonomy-manager';
import { canAutoExecute } from './autonomy-manager';
// === Types ===
@@ -348,8 +347,8 @@ export class SessionPersistenceService {
metadata: {
startedAt: session.startedAt,
endedAt: new Date().toISOString(),
messageCount: session.messageCount,
agentId: session.agentId,
messageCount: String(session.messageCount || 0),
agentId: session.agentId || 'default',
},
wait: false,
}

View File

@@ -11,6 +11,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { useTeamStore } from '../store/teamStore';
import { useGatewayStore } from '../store/gatewayStore';
import type { TeamEventMessage, TeamEventType, CollaborationEvent } from '../lib/team-client';
import { silentErrorHandler } from './error-utils';
interface UseTeamEventsOptions {
/** Subscribe to specific team only, or null for all teams */
@@ -82,7 +83,7 @@ export function useTeamEvents(options: UseTeamEventsOptions = {}) {
case 'member.added':
case 'member.removed':
// Reload teams to get updated data
loadTeams().catch(() => {});
loadTeams().catch(silentErrorHandler('useTeamEvents'));
break;
}
},

View File

@@ -123,14 +123,16 @@ export class VectorMemoryService {
importance: Math.round((1 - result.score) * 10), // Invert score to importance
createdAt: new Date().toISOString(),
source: 'auto',
tags: result.metadata?.tags ?? [],
tags: (result.metadata as Record<string, unknown>)?.tags ?? [],
lastAccessedAt: new Date().toISOString(),
accessCount: 0,
};
searchResults.push({
memory,
score: result.score,
uri: result.uri,
highlights: result.highlights,
highlights: (result.metadata as Record<string, unknown>)?.highlights as string[] | undefined,
});
}
@@ -155,8 +157,8 @@ export class VectorMemoryService {
): Promise<VectorSearchResult[]> {
// Get the memory content first
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(options?.agentId ?? 'default');
const memory = memories.find(m => m.id === memoryId);
const memories = await memoryManager.getAll(options?.agentId ?? 'default');
const memory = memories.find((m: MemoryEntry) => m.id === memoryId);
if (!memory) {
console.warn(`[VectorMemory] Memory not found: ${memoryId}`);
@@ -192,7 +194,7 @@ export class VectorMemoryService {
clusterCount: number = 5
): Promise<VectorSearchResult[][]> {
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(agentId);
const memories = await memoryManager.getAll(agentId);
if (memories.length === 0) {
return [];

View File

@@ -0,0 +1,425 @@
/**
* ActiveLearningStore - 主动学习状态管理
*
* 猡久学习事件和学习模式,学习建议的状态。
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
type LearningEvent,
type LearningPattern,
type LearningSuggestion,
type LearningEventType,
type LearningConfig,
} from '../types/active-learning';
// === Types ===
interface ActiveLearningState {
events: LearningEvent[];
patterns: LearningPattern[];
suggestions: LearningSuggestion[];
config: LearningConfig;
isLoading: boolean;
error: string | null;
}
interface ActiveLearningActions {
recordEvent: (event: Omit<LearningEvent, 'id' | 'timestamp' | 'acknowledged'>) => Promise<LearningEvent>;
recordFeedback: (agentId: string, messageId: string, feedback: string, context?: string) => Promise<LearningEvent | null>;
acknowledgeEvent: (eventId: string) => void;
getPatterns: (agentId: string) => LearningPattern[];
getSuggestions: (agentId: string) => LearningSuggestion[];
applySuggestion: (suggestionId: string) => void;
dismissSuggestion: (suggestionId: string) => void;
getStats: (agentId: string) => ActiveLearningStats;
setConfig: (config: Partial<LearningConfig>) => void;
clearEvents: (agentId: string) => void;
exportLearningData: (agentId: string) => Promise<string>;
importLearningData: (agentId: string, data: string) => Promise<void>;
}
interface ActiveLearningStats {
totalEvents: number;
eventsByType: Record<LearningEventType, number>;
totalPatterns: number;
avgConfidence: number;
}
export type ActiveLearningStore = ActiveLearningState & ActiveLearningActions;
const STORAGE_KEY = 'zclaw-active-learning';
const MAX_EVENTS = 1000;
// === Helper Functions ===
function generateEventId(): string {
return `le-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function analyzeSentiment(text: string): 'positive' | 'negative' | 'neutral' {
const positive = ['好的', '很棒', '谢谢', '完美', 'excellent', '喜欢', '爱了', 'good', 'great', 'nice', '满意'];
const negative = ['不好', '差', '糟糕', '错误', 'wrong', 'bad', '不喜欢', '讨厌', '问题', '失败', 'fail', 'error'];
const lowerText = text.toLowerCase();
if (positive.some(w => lowerText.includes(w))) return 'positive';
if (negative.some(w => lowerText.includes(w))) return 'negative';
return 'neutral';
}
function analyzeEventType(text: string): LearningEventType {
const lowerText = text.toLowerCase();
if (lowerText.includes('纠正') || lowerText.includes('不对') || lowerText.includes('修改')) {
return 'correction';
}
if (lowerText.includes('喜欢') || lowerText.includes('偏好') || lowerText.includes('风格')) {
return 'preference';
}
if (lowerText.includes('场景') || lowerText.includes('上下文') || lowerText.includes('情况')) {
return 'context';
}
if (lowerText.includes('总是') || lowerText.includes('经常') || lowerText.includes('习惯')) {
return 'behavior';
}
return 'feedback';
}
function inferPreference(feedback: string, sentiment: string): string {
if (sentiment === 'positive') {
if (feedback.includes('简洁')) return '用户偏好简洁的回复';
if (feedback.includes('详细')) return '用户偏好详细的回复';
if (feedback.includes('快速')) return '用户偏好快速响应';
return '用户对当前回复风格满意';
}
if (sentiment === 'negative') {
if (feedback.includes('太长')) return '用户偏好更短的回复';
if (feedback.includes('太短')) return '用户偏好更详细的回复';
if (feedback.includes('不准确')) return '用户偏好更准确的信息';
return '用户对当前回复风格不满意';
}
return '用户反馈中性';
}
// === Store ===
export const useActiveLearningStore = create<ActiveLearningStore>()(
persist(
(set, get) => ({
events: [],
patterns: [],
suggestions: [],
config: {
enabled: true,
minConfidence: 0.5,
maxEvents: MAX_EVENTS,
suggestionCooldown: 2,
},
isLoading: false,
error: null,
recordEvent: async (event) => {
const { events, config } = get();
if (!config.enabled) throw new Error('Learning is disabled');
// 检查重复事件
const existing = events.find(e =>
e.agentId === event.agentId &&
e.messageId === event.messageId &&
e.type === event.type
);
if (existing) {
// 更新现有事件
const updated = events.map(e =>
e.id === existing.id
? {
...e,
observation: e.observation + ' | ' + event.observation,
confidence: (e.confidence + event.confidence) / 2,
appliedCount: e.appliedCount + 1,
}
: e
);
set({ events: updated });
return existing;
}
// 创建新事件
const newEvent: LearningEvent = {
...event,
id: generateEventId(),
timestamp: Date.now(),
acknowledged: false,
appliedCount: 0,
};
// 提取模式
const newPatterns = extractPatterns(newEvent, get().patterns);
const newSuggestions = generateSuggestions(newEvent, newPatterns);
// 保持事件数量限制
const updatedEvents = [newEvent, ...events].slice(0, config.maxEvents);
set({
events: updatedEvents,
patterns: [...get().patterns, ...newPatterns],
suggestions: [...get().suggestions, ...newSuggestions],
});
return newEvent;
},
recordFeedback: async (agentId, messageId, feedback, context) => {
const { config } = get();
if (!config.enabled) return null;
const sentiment = analyzeSentiment(feedback);
const type = analyzeEventType(feedback);
return get().recordEvent({
type,
agentId,
messageId,
trigger: context || 'User feedback',
observation: feedback,
context,
inferredPreference: inferPreference(feedback, sentiment),
confidence: sentiment === 'positive' ? 0.8 : sentiment === 'negative' ? 0.5 : 0.3,
appliedCount: 0,
});
},
acknowledgeEvent: (eventId) => {
const { events } = get();
set({
events: events.map(e =>
e.id === eventId ? { ...e, acknowledged: true } : e
),
});
},
getPatterns: (agentId) => {
return get().patterns.filter(p => p.agentId === agentId);
},
getSuggestions: (agentId) => {
const now = Date.now();
return get().suggestions.filter(s =>
s.agentId === agentId &&
!s.dismissed &&
(!s.expiresAt || s.expiresAt.getTime() > now)
);
},
applySuggestion: (suggestionId) => {
const { suggestions, patterns } = get();
const suggestion = suggestions.find(s => s.id === suggestionId);
if (suggestion) {
// 更新模式置信度
const updatedPatterns = patterns.map(p =>
p.pattern === suggestion.pattern
? { ...p, confidence: Math.min(1, p.confidence + 0.1) }
: p
);
set({
suggestions: suggestions.map(s =>
s.id === suggestionId ? { ...s, dismissed: false } : s
),
patterns: updatedPatterns,
});
}
},
dismissSuggestion: (suggestionId) => {
const { suggestions } = get();
set({
suggestions: suggestions.map(s =>
s.id === suggestionId ? { ...s, dismissed: true } : s
),
});
},
getStats: (agentId) => {
const { events, patterns } = get();
const agentEvents = events.filter(e => e.agentId === agentId);
const agentPatterns = patterns.filter(p => p.agentId === agentId);
const eventsByType: Record<LearningEventType, number> = {
preference: 0,
correction: 0,
context: 0,
feedback: 0,
behavior: 0,
implicit: 0,
};
for (const event of agentEvents) {
eventsByType[event.type]++;
}
return {
totalEvents: agentEvents.length,
eventsByType,
totalPatterns: agentPatterns.length,
avgConfidence: agentPatterns.length > 0
? agentPatterns.reduce((sum, p) => sum + p.confidence, 0) / agentPatterns.length
: 0,
};
},
setConfig: (config) => {
set(state => ({
config: { ...state.config, ...config },
}));
},
clearEvents: (agentId) => {
const { events, patterns, suggestions } = get();
set({
events: events.filter(e => e.agentId !== agentId),
patterns: patterns.filter(p => p.agentId !== agentId),
suggestions: suggestions.filter(s => s.agentId !== agentId),
});
},
exportLearningData: async (agentId) => {
const { events, patterns, config } = get();
const data = {
events: events.filter(e => e.agentId === agentId),
patterns: patterns.filter(p => p.agentId === agentId),
config,
exportedAt: new Date().toISOString(),
};
return JSON.stringify(data, null, 2);
},
importLearningData: async (agentId, data) => {
try {
const parsed = JSON.parse(data);
const { events, patterns } = get();
// 合并导入的数据
const mergedEvents = [
...events,
...parsed.events.map((e: LearningEvent) => ({
...e,
id: generateEventId(),
agentId,
})),
].slice(0, MAX_EVENTS);
const mergedPatterns = [
...patterns,
...parsed.patterns.map((p: LearningPattern) => ({
...p,
agentId,
})),
];
set({
events: mergedEvents,
patterns: mergedPatterns,
});
} catch (err) {
throw new Error(`Failed to import learning data: ${err}`);
}
},
}),
{
name: STORAGE_KEY,
}
)
);
// === Pattern Extraction ===
function extractPatterns(
event: LearningEvent,
existingPatterns: LearningPattern[]
): LearningPattern[] {
const patterns: LearningPattern[] = [];
// 偏好模式
if (event.observation.includes('谢谢') || event.observation.includes('好的')) {
patterns.push({
type: 'preference',
pattern: 'positive_response_preference',
description: '用户偏好正面回复风格',
examples: [event.observation],
confidence: 0.8,
agentId: event.agentId,
});
}
// 精确性模式
if (event.type === 'correction') {
patterns.push({
type: 'rule',
pattern: 'precision_preference',
description: '用户对精确性有更高要求',
examples: [event.observation],
confidence: 0.9,
agentId: event.agentId,
});
}
// 上下文模式
if (event.context) {
patterns.push({
type: 'context',
pattern: 'context_aware',
description: 'Agent 需要关注上下文',
examples: [event.context],
confidence: 0.6,
agentId: event.agentId,
});
}
return patterns.filter(p =>
!existingPatterns.some(ep => ep.pattern === p.pattern && ep.agentId === p.agentId)
);
}
// === Suggestion Generation ===
function generateSuggestions(
event: LearningEvent,
patterns: LearningPattern[]
): LearningSuggestion[] {
const suggestions: LearningSuggestion[] = [];
const now = Date.now();
for (const pattern of patterns) {
const template = SUGGESTION_TEMPLATES[pattern.pattern];
if (template) {
suggestions.push({
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2)}`,
agentId: event.agentId,
type: pattern.type,
pattern: pattern.pattern,
suggestion: template,
confidence: pattern.confidence,
createdAt: now,
expiresAt: new Date(now + 7 * 24 * 60 * 60 * 1000),
dismissed: false,
});
}
}
return suggestions;
}
const SUGGESTION_TEMPLATES: Record<string, string> = {
positive_response_preference:
'用户似乎偏好正面回复。建议在回复时保持积极和确认的语气。',
precision_preference:
'用户对精确性有更高要求。建议在提供信息时更加详细和准确。',
context_aware:
'Agent 需要关注上下文。建议在回复时考虑对话的背景和历史。',
};

View File

@@ -26,6 +26,12 @@ export interface Clone {
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
// 人格相关字段
emoji?: string; // Agent emoji, e.g., "🦞", "🤖", "💻"
personality?: string; // 人格风格: professional, friendly, creative, concise
communicationStyle?: string; // 沟通风格描述
notes?: string; // 用户备注
onboardingCompleted?: boolean; // 是否完成首次引导
}
export interface UsageStats {
@@ -54,11 +60,16 @@ export interface CloneCreateOptions {
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
// 人格相关字段
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}
// === Store State ===
interface AgentStateSlice {
export interface AgentStateSlice {
clones: Clone[];
usageStats: UsageStats | null;
pluginStatus: PluginStatus[];
@@ -68,7 +79,7 @@ interface AgentStateSlice {
// === Store Actions ===
interface AgentActionsSlice {
export interface AgentActionsSlice {
loadClones: () => Promise<void>;
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;

View File

@@ -350,19 +350,12 @@ export const useChatStore = create<ChatState>()(
const client = getGatewayClient();
// Try streaming first (OpenFang WebSocket)
// Note: onDelta is empty - stream updates handled by initStreamListener to avoid duplication
if (client.getState() === 'connected') {
const { runId } = await client.chatStream(
enhancedContent,
{
onDelta: (delta: string) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === assistantId
? { ...m, content: m.content + delta }
: m
),
}));
},
onDelta: () => { /* Handled by initStreamListener to prevent duplication */ },
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
@@ -395,7 +388,7 @@ export const useChatStore = create<ChatState>()(
set((state) => ({
isStreaming: false,
messages: state.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m
m.id === assistantId ? { ...m, streaming: false, runId } : m
),
}));
// Async memory extraction after stream completes
@@ -634,6 +627,8 @@ export const useChatStore = create<ChatState>()(
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
messages: state.messages,
currentConversationId: state.currentConversationId,
}),
onRehydrateStorage: () => (state) => {
// Rehydrate Date objects from JSON strings

View File

@@ -6,6 +6,7 @@
*/
import { create } from 'zustand';
import type { GatewayModelChoice } from '../lib/gateway-config';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
@@ -121,10 +122,9 @@ export interface ConfigStoreClient {
getFeishuStatus(): Promise<{ configured?: boolean; accounts?: number } | null>;
}
// === Store State & Actions ===
// === Store State Slice ===
interface ConfigStore {
// State
export interface ConfigStateSlice {
quickConfig: QuickConfig;
workspaceInfo: WorkspaceInfo | null;
channels: ChannelInfo[];
@@ -134,21 +134,16 @@ interface ConfigStore {
modelsLoading: boolean;
modelsError: string | null;
error: string | null;
// Client reference (injected)
client: ConfigStoreClient | null;
}
// Client injection
// === Store Actions Slice ===
export interface ConfigActionsSlice {
setConfigStoreClient: (client: ConfigStoreClient) => void;
// Quick Config Actions
loadQuickConfig: () => Promise<void>;
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
// Workspace Actions
loadWorkspaceInfo: () => Promise<void>;
// Channel Actions
loadChannels: () => Promise<void>;
getChannel: (id: string) => Promise<ChannelInfo | undefined>;
createChannel: (channel: {
@@ -163,8 +158,6 @@ interface ConfigStore {
enabled?: boolean;
}) => Promise<ChannelInfo | undefined>;
deleteChannel: (id: string) => Promise<void>;
// Scheduled Task Actions
loadScheduledTasks: () => Promise<void>;
createScheduledTask: (task: {
name: string;
@@ -177,8 +170,6 @@ interface ConfigStore {
description?: string;
enabled?: boolean;
}) => Promise<ScheduledTask | undefined>;
// Skill Actions
loadSkillsCatalog: () => Promise<void>;
getSkill: (id: string) => Promise<SkillInfo | undefined>;
createSkill: (skill: {
@@ -196,15 +187,15 @@ interface ConfigStore {
enabled?: boolean;
}) => Promise<SkillInfo | undefined>;
deleteSkill: (id: string) => Promise<void>;
// Model Actions
loadModels: () => Promise<void>;
// Utility
clearError: () => void;
}
export const useConfigStore = create<ConfigStore>((set, get) => ({
// === Combined Store Type ===
export type ConfigStore = ConfigStateSlice & ConfigActionsSlice;
export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set, get) => ({
// Initial State
quickConfig: {},
workspaceInfo: null,
@@ -535,3 +526,47 @@ export type {
ScheduledTask as ScheduledTaskType,
SkillInfo as SkillInfoType,
};
// === Client Injection ===
/**
* Helper to create a ConfigStoreClient adapter from a GatewayClient.
*/
function createConfigClientFromGateway(client: GatewayClient): ConfigStoreClient {
return {
getWorkspaceInfo: () => client.getWorkspaceInfo(),
getQuickConfig: () => client.getQuickConfig(),
saveQuickConfig: (config) => client.saveQuickConfig(config),
listSkills: () => client.listSkills(),
getSkill: (id) => client.getSkill(id),
createSkill: (skill) => client.createSkill(skill),
updateSkill: (id, updates) => client.updateSkill(id, updates),
deleteSkill: (id) => client.deleteSkill(id),
listChannels: () => client.listChannels(),
getChannel: (id) => client.getChannel(id),
createChannel: (channel) => client.createChannel(channel),
updateChannel: (id, updates) => client.updateChannel(id, updates),
deleteChannel: (id) => client.deleteChannel(id),
listScheduledTasks: () => client.listScheduledTasks(),
createScheduledTask: async (task) => {
const result = await client.createScheduledTask(task);
return {
id: result.id,
name: result.name,
schedule: result.schedule,
status: result.status as 'active' | 'paused' | 'completed' | 'error',
};
},
listModels: () => client.listModels(),
getFeishuStatus: () => client.getFeishuStatus(),
};
}
/**
* Sets the client for the config store.
* Called by the coordinator during initialization.
*/
export function setConfigStoreClient(client: unknown): void {
const configClient = createConfigClientFromGateway(client as GatewayClient);
useConfigStore.getState().setConfigStoreClient(configClient);
}

View File

@@ -14,7 +14,7 @@ import {
import {
isTauriRuntime,
prepareLocalGatewayForTauri,
getLocalGatewayStatus,
getLocalGatewayStatus as fetchLocalGatewayStatus,
startLocalGateway as startLocalGatewayCommand,
stopLocalGateway as stopLocalGatewayCommand,
restartLocalGateway as restartLocalGatewayCommand,
@@ -23,6 +23,7 @@ import {
getUnsupportedLocalGatewayStatus,
type LocalGatewayStatus,
} from '../lib/tauri-gateway';
import { useConfigStore } from './configStore';
// === Types ===
@@ -59,18 +60,6 @@ function requiresLocalDevicePairing(error: unknown): boolean {
return message.includes('pairing required');
}
/**
* Calculate security level based on enabled layer count.
*/
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
if (totalCount === 0) return 'low';
const ratio = enabledCount / totalCount;
if (ratio >= 0.875) return 'critical'; // 14-16 layers
if (ratio >= 0.625) return 'high'; // 10-13 layers
if (ratio >= 0.375) return 'medium'; // 6-9 layers
return 'low'; // 0-5 layers
}
/**
* Check if a URL is a loopback address.
*/
@@ -187,7 +176,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// Check local gateway first if in Tauri
if (isTauriRuntime()) {
try {
const localStatus = await getLocalGatewayStatus();
const localStatus = await fetchLocalGatewayStatus();
const localUrl = getLocalGatewayConnectUrl(localStatus);
if (localUrl) {
candidates.push(localUrl);
@@ -198,7 +187,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
}
// Add quick config gateway URL if available
const quickConfigGatewayUrl = get().quickConfig?.gatewayUrl?.trim();
const quickConfigGatewayUrl = useConfigStore.getState().quickConfig?.gatewayUrl?.trim();
if (quickConfigGatewayUrl) {
candidates.push(quickConfigGatewayUrl);
}
@@ -233,7 +222,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
}
// Resolve effective token: param > quickConfig > localStorage > local auth
let effectiveToken = token || get().quickConfig?.gatewayToken || getStoredGatewayToken();
let effectiveToken = token || useConfigStore.getState().quickConfig?.gatewayToken || getStoredGatewayToken();
if (!effectiveToken && isTauriRuntime()) {
try {
const localAuth = await getLocalGatewayAuth();
@@ -246,7 +235,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
}
}
console.log('[ConnectionStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
console.log('[ConnectionStore] Connecting with token:', effectiveToken ? '[REDACTED]' : '(empty)');
const candidateUrls = await resolveCandidates();
let lastError: unknown = null;
@@ -327,7 +316,7 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
set({ localGatewayBusy: true });
try {
const status = await getLocalGatewayStatus();
const status = await fetchLocalGatewayStatus();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: unknown) {

View File

@@ -27,6 +27,12 @@ interface Clone {
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
// 人格相关字段
emoji?: string; // Agent emoji, e.g., "🦞", "🤖", "💻"
personality?: string; // 人格风格: professional, friendly, creative, concise
communicationStyle?: string; // 沟通风格描述
notes?: string; // 用户备注
onboardingCompleted?: boolean; // 是否完成首次引导
}
interface UsageStats {
@@ -93,6 +99,11 @@ interface QuickConfig {
autoSaveContext?: boolean;
fileWatching?: boolean;
privacyOptIn?: boolean;
// 人格相关字段
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}
interface WorkspaceInfo {
@@ -746,7 +757,8 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
loadClones: async () => {
try {
const result = await get().client.listClones();
const clones = result?.clones || result?.agents || [];
// API 可能返回数组,也可能返回 {clones: [...]} 或 {agents: [...]}
const clones = Array.isArray(result) ? result : (result?.clones || result?.agents || []);
set({ clones });
useChatStore.getState().syncAgents(clones);
@@ -1221,7 +1233,7 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
try {
const result = await get().client.listHandRuns(name, opts);
const runs: HandRun[] = (result?.runs || []).map((r: RawHandRun) => ({
runId: r.runId || r.run_id || r.id,
runId: r.runId || r.run_id || r.id || '',
status: r.status || 'unknown',
startedAt: r.startedAt || r.started_at || r.created_at || new Date().toISOString(),
completedAt: r.completedAt || r.completed_at || r.finished_at,
@@ -1486,15 +1498,15 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
try {
const result = await get().client.listApprovals(status);
const approvals: Approval[] = (result?.approvals || []).map((a: RawApproval) => ({
id: a.id || a.approval_id,
handName: a.hand_name || a.handName,
runId: a.run_id || a.runId,
id: a.id || a.approval_id || '',
handName: a.hand_name || a.handName || '',
runId: a.run_id || a.runId || '',
status: a.status || 'pending',
requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(),
requestedBy: a.requested_by || a.requestedBy,
reason: a.reason || a.description,
requestedBy: a.requested_by || a.requestedBy || '',
reason: a.reason || a.description || '',
action: a.action || 'execute',
params: a.params,
params: a.params || {},
respondedAt: a.responded_at || a.respondedAt,
respondedBy: a.responded_by || a.respondedBy,
responseReason: a.response_reason || a.responseReason,

View File

@@ -65,6 +65,17 @@ export interface Approval {
responseReason?: string;
}
// === Trigger Create Options ===
export interface TriggerCreateOptions {
type: string;
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
handName?: string;
workflowId?: string;
}
// === Raw API Response Types (for mapping) ===
interface RawHandRequirement {
@@ -129,30 +140,32 @@ interface HandClient {
getHand: (name: string) => Promise<Record<string, unknown> | null>;
listHandRuns: (name: string, opts?: { limit?: number; offset?: number }) => Promise<{ runs?: RawHandRun[] } | null>;
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<{ runId?: string; status?: string } | null>;
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
cancelHand: (name: string, runId: string) => Promise<void>;
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<{ status: string }>;
cancelHand: (name: string, runId: string) => Promise<{ status: string }>;
listTriggers: () => Promise<{ triggers?: Trigger[] } | null>;
getTrigger: (id: string) => Promise<Trigger | null>;
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<{ id?: string } | null>;
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<void>;
deleteTrigger: (id: string) => Promise<void>;
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<{ id: string }>;
deleteTrigger: (id: string) => Promise<{ status: string }>;
listApprovals: (status?: ApprovalStatus) => Promise<{ approvals?: RawApproval[] } | null>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<{ status: string }>;
}
interface HandStore {
// State
// === Store State Slice ===
export interface HandStateSlice {
hands: Hand[];
handRuns: Record<string, HandRun[]>;
triggers: Trigger[];
approvals: Approval[];
isLoading: boolean;
error: string | null;
// Client reference (injected via setHandStoreClient)
client: HandClient | null;
}
// Actions
// === Store Actions Slice ===
export interface HandActionsSlice {
setHandStoreClient: (client: HandClient) => void;
loadHands: () => Promise<void>;
getHandDetails: (name: string) => Promise<Hand | undefined>;
@@ -162,7 +175,7 @@ interface HandStore {
cancelHand: (name: string, runId: string) => Promise<void>;
loadTriggers: () => Promise<void>;
getTrigger: (id: string) => Promise<Trigger | undefined>;
createTrigger: (trigger: { type: string; name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
createTrigger: (trigger: TriggerCreateOptions) => Promise<Trigger | undefined>;
updateTrigger: (id: string, updates: { name?: string; enabled?: boolean; config?: Record<string, unknown>; handName?: string; workflowId?: string }) => Promise<Trigger | undefined>;
deleteTrigger: (id: string) => Promise<void>;
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
@@ -170,6 +183,10 @@ interface HandStore {
clearError: () => void;
}
// === Combined Store Type ===
export type HandStore = HandStateSlice & HandActionsSlice;
export const useHandStore = create<HandStore>((set, get) => ({
// Initial State
hands: [],
@@ -383,7 +400,7 @@ export const useHandStore = create<HandStore>((set, get) => ({
}
},
createTrigger: async (trigger) => {
createTrigger: async (trigger: TriggerCreateOptions) => {
const client = get().client;
if (!client) return undefined;
@@ -496,3 +513,14 @@ export function createHandClientFromGateway(client: GatewayClient): HandClient {
respondToApproval: (approvalId, approved, reason) => client.respondToApproval(approvalId, approved, reason),
};
}
// === Client Injection ===
/**
* Sets the client for the hand store.
* Called by the coordinator during initialization.
*/
export function setHandStoreClient(client: unknown): void {
const handClient = createHandClientFromGateway(client as GatewayClient);
useHandStore.getState().setHandStoreClient(handClient);
}

View File

@@ -26,15 +26,21 @@ export type { WorkflowStore, WorkflowStateSlice, WorkflowActionsSlice, Workflow,
export { useConfigStore, setConfigStoreClient } from './configStore';
export type { ConfigStore, ConfigStateSlice, ConfigActionsSlice, QuickConfig, WorkspaceInfo, ChannelInfo, ScheduledTask, SkillInfo } from './configStore';
// === New Stores ===
export { useMemoryGraphStore } from './memoryGraphStore';
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
export { useActiveLearningStore } from './activeLearningStore';
export type { ActiveLearningStore } from './activeLearningStore';
// === Composite Store Hook ===
import { useEffect, useMemo } from 'react';
import { useMemo } from 'react';
import { useConnectionStore, getClient } from './connectionStore';
import { useAgentStore, setAgentStoreClient } from './agentStore';
import { useHandStore, setHandStoreClient } from './handStore';
import { useWorkflowStore, setWorkflowStoreClient } from './workflowStore';
import { useConfigStore, setConfigStoreClient } from './configStore';
import type { GatewayClient } from '../lib/gateway-client';
/**
* Initialize all stores with the shared client.
@@ -113,7 +119,7 @@ export function useCompositeStore() {
const createTrigger = useHandStore((s) => s.createTrigger);
const deleteTrigger = useHandStore((s) => s.deleteTrigger);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const approveRequest = useHandStore((s) => s.approveRequest);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const getWorkflow = useWorkflowStore((s) => s.getWorkflow);
@@ -203,7 +209,7 @@ export function useCompositeStore() {
createTrigger,
deleteTrigger,
loadApprovals,
approveRequest,
respondToApproval,
// Workflow actions
loadWorkflows,
@@ -244,7 +250,7 @@ export function useCompositeStore() {
quickConfig, workspaceInfo, channels, scheduledTasks, skillsCatalog, models, modelsLoading, modelsError,
connect, disconnect, clearLogs, refreshLocalGateway, startLocalGateway, stopLocalGateway, restartLocalGateway,
loadClones, createClone, updateClone, deleteClone, loadUsageStats, loadPluginStatus,
loadHands, getHandDetails, triggerHand, loadHandRuns, loadTriggers, createTrigger, deleteTrigger, loadApprovals, approveRequest,
loadHands, getHandDetails, triggerHand, loadHandRuns, loadTriggers, createTrigger, deleteTrigger, loadApprovals, respondToApproval,
loadWorkflows, getWorkflow, createWorkflow, updateWorkflow, deleteWorkflow, triggerWorkflow, loadWorkflowRuns,
loadQuickConfig, saveQuickConfig, loadWorkspaceInfo, loadChannels, getChannel, createChannel, updateChannel, deleteChannel,
loadScheduledTasks, createScheduledTask, loadSkillsCatalog, getSkill, createSkill, updateSkill, deleteSkill, loadModels,

View File

@@ -0,0 +1,316 @@
/**
* MemoryGraphStore - 记忆图谱状态管理
*
* 管理记忆图谱可视化的状态,包括节点、边、布局和交互。
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getMemoryManager, type MemoryEntry, type MemoryType } from '../lib/agent-memory';
export type { MemoryType };
// === Types ===
export interface GraphNode {
id: string;
type: MemoryType;
label: string;
x: number;
y: number;
vx: number;
vy: number;
importance: number;
accessCount: number;
createdAt: string;
isHighlighted: boolean;
isSelected: boolean;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
type: 'reference' | 'related' | 'derived';
strength: number;
}
export interface GraphFilter {
types: MemoryType[];
minImportance: number;
dateRange: {
start?: string;
end?: string;
};
searchQuery: string;
}
export interface GraphLayout {
width: number;
height: number;
zoom: number;
offsetX: number;
offsetY: number;
}
interface MemoryGraphState {
nodes: GraphNode[];
edges: GraphEdge[];
isLoading: boolean;
error: string | null;
filter: GraphFilter;
layout: GraphLayout;
selectedNodeId: string | null;
hoveredNodeId: string | null;
showLabels: boolean;
simulationRunning: boolean;
}
interface MemoryGraphActions {
loadGraph: (agentId: string) => Promise<void>;
setFilter: (filter: Partial<GraphFilter>) => void;
resetFilter: () => void;
setLayout: (layout: Partial<GraphLayout>) => void;
selectNode: (nodeId: string | null) => void;
hoverNode: (nodeId: string | null) => void;
toggleLabels: () => void;
startSimulation: () => void;
stopSimulation: () => void;
updateNodePositions: (updates: Array<{ id: string; x: number; y: number }>) => void;
highlightSearch: (query: string) => void;
clearHighlight: () => void;
exportAsImage: () => Promise<Blob | null>;
getFilteredNodes: () => GraphNode[];
getFilteredEdges: () => GraphEdge[];
}
const DEFAULT_FILTER: GraphFilter = {
types: ['fact', 'preference', 'lesson', 'context', 'task'],
minImportance: 0,
dateRange: {},
searchQuery: '',
};
const DEFAULT_LAYOUT: GraphLayout = {
width: 800,
height: 600,
zoom: 1,
offsetX: 0,
offsetY: 0,
};
export type MemoryGraphStore = MemoryGraphState & MemoryGraphActions;
// === Helper Functions ===
function memoryToNode(memory: MemoryEntry, index: number, total: number): GraphNode {
// 使用圆形布局初始位置
const angle = (index / total) * 2 * Math.PI;
const radius = 200;
return {
id: memory.id,
type: memory.type,
label: memory.content.slice(0, 50) + (memory.content.length > 50 ? '...' : ''),
x: 400 + radius * Math.cos(angle),
y: 300 + radius * Math.sin(angle),
vx: 0,
vy: 0,
importance: memory.importance,
accessCount: memory.accessCount,
createdAt: memory.createdAt,
isHighlighted: false,
isSelected: false,
};
}
function findRelatedMemories(memories: MemoryEntry[]): GraphEdge[] {
const edges: GraphEdge[] = [];
// 简单的关联算法:基于共同标签和关键词
for (let i = 0; i < memories.length; i++) {
for (let j = i + 1; j < memories.length; j++) {
const m1 = memories[i];
const m2 = memories[j];
// 检查共同标签
const commonTags = m1.tags.filter(t => m2.tags.includes(t));
if (commonTags.length > 0) {
edges.push({
id: `edge-${m1.id}-${m2.id}`,
source: m1.id,
target: m2.id,
type: 'related',
strength: commonTags.length * 0.3,
});
}
// 同类型记忆关联
if (m1.type === m2.type) {
const existingEdge = edges.find(
e => e.source === m1.id && e.target === m2.id
);
if (!existingEdge) {
edges.push({
id: `edge-${m1.id}-${m2.id}-type`,
source: m1.id,
target: m2.id,
type: 'derived',
strength: 0.1,
});
}
}
}
}
return edges;
}
export const useMemoryGraphStore = create<MemoryGraphStore>()(
persist(
(set, get) => ({
nodes: [],
edges: [],
isLoading: false,
error: null,
filter: DEFAULT_FILTER,
layout: DEFAULT_LAYOUT,
selectedNodeId: null,
hoveredNodeId: null,
showLabels: true,
simulationRunning: false,
loadGraph: async (agentId: string) => {
set({ isLoading: true, error: null });
try {
const mgr = getMemoryManager();
const memories = await mgr.getAll(agentId, { limit: 200 });
const nodes = memories.map((m, i) => memoryToNode(m, i, memories.length));
const edges = findRelatedMemories(memories);
set({
nodes,
edges,
isLoading: false,
});
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : '加载图谱失败',
});
}
},
setFilter: (filter) => {
set(state => ({
filter: { ...state.filter, ...filter },
}));
},
resetFilter: () => {
set({ filter: DEFAULT_FILTER });
},
setLayout: (layout) => {
set(state => ({
layout: { ...state.layout, ...layout },
}));
},
selectNode: (nodeId) => {
set(state => ({
selectedNodeId: nodeId,
nodes: state.nodes.map(n => ({
...n,
isSelected: n.id === nodeId,
})),
}));
},
hoverNode: (nodeId) => {
set(state => ({
hoveredNodeId: nodeId,
nodes: state.nodes.map(n => ({
...n,
isHighlighted: nodeId ? n.id === nodeId : n.isHighlighted,
})),
}));
},
toggleLabels: () => {
set(state => ({ showLabels: !state.showLabels }));
},
startSimulation: () => {
set({ simulationRunning: true });
},
stopSimulation: () => {
set({ simulationRunning: false });
},
updateNodePositions: (updates) => {
set(state => ({
nodes: state.nodes.map(node => {
const update = updates.find(u => u.id === node.id);
return update ? { ...node, x: update.x, y: update.y } : node;
}),
}));
},
highlightSearch: (query) => {
const lowerQuery = query.toLowerCase();
set(state => ({
filter: { ...state.filter, searchQuery: query },
nodes: state.nodes.map(n => ({
...n,
isHighlighted: query ? n.label.toLowerCase().includes(lowerQuery) : false,
})),
}));
},
clearHighlight: () => {
set(state => ({
nodes: state.nodes.map(n => ({ ...n, isHighlighted: false })),
}));
},
exportAsImage: async () => {
// SVG 导出逻辑在组件中实现
return null;
},
getFilteredNodes: () => {
const { nodes, filter } = get();
return nodes.filter(n => {
if (!filter.types.includes(n.type)) return false;
if (n.importance < filter.minImportance) return false;
if (filter.dateRange.start && n.createdAt < filter.dateRange.start) return false;
if (filter.dateRange.end && n.createdAt > filter.dateRange.end) return false;
if (filter.searchQuery) {
return n.label.toLowerCase().includes(filter.searchQuery.toLowerCase());
}
return true;
});
},
getFilteredEdges: () => {
const { edges } = get();
const filteredNodes = get().getFilteredNodes();
const nodeIds = new Set(filteredNodes.map(n => n.id));
return edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target));
},
}),
{
name: 'zclaw-memory-graph',
partialize: (state) => ({
filter: state.filter,
layout: state.layout,
showLabels: state.showLabels,
}),
}
)
);

View File

@@ -0,0 +1,411 @@
/**
* * skillMarketStore.ts - 技能市场状态管理
*
* * 猛攻状态管理技能浏览、搜索、安装/卸载等功能
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Skill, SkillReview, SkillMarketState } from '../types/skill-market';
// === 存储键 ===
const STORAGE_KEY = 'zclaw-skill-market';
const INSTALLED_KEY = 'zclaw-installed-skills';
// === 默认状态 ===
const initialState: SkillMarketState = {
skills: [],
installedSkills: [],
searchResults: [],
selectedSkill: null,
searchQuery: '',
categoryFilter: 'all',
isLoading: false,
error: null,
};
// === Store 定义 ===
interface SkillMarketActions {
// 技能加载
loadSkills: () => Promise<void>;
// 技能搜索
searchSkills: (query: string) => void;
// 分类过滤
filterByCategory: (category: string) => void;
// 选择技能
selectSkill: (skill: Skill | null) => void;
// 安装技能
installSkill: (skillId: string) => Promise<boolean>;
// 卸载技能
uninstallSkill: (skillId: string) => Promise<boolean>;
// 获取技能详情
getSkillDetails: (skillId: string) => Promise<Skill | null>;
// 加载评论
loadReviews: (skillId: string) => Promise<SkillReview[]>;
// 添加评论
addReview: (skillId: string, review: Omit<SkillReview, 'id' | 'skillId' | 'createdAt'>) => Promise<void>;
// 刷新技能列表
refreshSkills: () => Promise<void>;
// 清除错误
clearError: () => void;
// 重置状态
reset: () => void;
}
// === Store 创建 ===
export const useSkillMarketStore = create<SkillMarketState & SkillMarketActions>()(
persist({
key: STORAGE_KEY,
storage: localStorage,
partialize: (state) => ({
installedSkills: state.installedSkills,
categoryFilter: state.categoryFilter,
}),
}),
initialState,
{
// === 技能加载 ===
loadSkills: async () => {
set({ isLoading: true, error: null });
try {
// 扫描 skills 目录获取可用技能
const skills = await scanSkillsDirectory();
// 从 localStorage 恢复安装状态
const stored = localStorage.getItem(INSTALLED_KEY);
const installedSkills: string[] = stored ? JSON.parse(stored) : [];
// 更新技能的安装状态
const updatedSkills = skills.map(skill => ({
...skill,
installed: installedSkills.includes(skill.id),
})));
set({
skills: updatedSkills,
installedSkills,
isLoading: false,
});
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : '加载技能失败',
});
}
},
// === 技能搜索 ===
searchSkills: (query: string) => {
const { skills } = get();
set({ searchQuery: query });
if (!query.trim()) {
set({ searchResults: [] });
return;
}
const queryLower = query.toLowerCase();
const results = skills.filter(skill => {
return (
skill.name.toLowerCase().includes(queryLower) ||
skill.description.toLowerCase().includes(queryLower) ||
skill.triggers.some(t => t.toLowerCase().includes(queryLower)) ||
skill.capabilities.some(c => c.toLowerCase().includes(queryLower)) ||
skill.tags?.some(t => t.toLowerCase().includes(queryLower))
);
});
set({ searchResults: results });
},
// === 分类过滤 ===
filterByCategory: (category: string) => {
set({ categoryFilter: category });
},
// === 选择技能 ===
selectSkill: (skill: Skill | null) => {
set({ selectedSkill: skill });
},
// === 安装技能 ===
installSkill: async (skillId: string) => {
const { skills, installedSkills } = get();
const skill = skills.find(s => s.id === skillId);
if (!skill) return false;
try {
// 更新安装状态
const newInstalledSkills = [...installedSkills, skillId];
const updatedSkills = skills.map(s => ({
...s,
installed: s.id === skillId ? true : s.installed,
installedAt: s.id === skillId ? new Date().toISOString() : s.installedAt,
}));
// 持久化安装列表
localStorage.setItem(INSTALLED_KEY, JSON.stringify(newInstalledSkills));
set({
skills: updatedSkills,
installedSkills: newInstalledSkills,
});
return true;
} catch (err) {
set({
error: err instanceof Error ? err.message : '安装技能失败',
});
return false;
}
},
// === 卸载技能 ===
uninstallSkill: async (skillId: string) => {
const { skills, installedSkills } = get();
try {
// 更新安装状态
const newInstalledSkills = installedSkills.filter(id => id !== skillId);
const updatedSkills = skills.map(s => ({
...s,
installed: s.id === skillId ? false : s.installed,
installedAt: s.id === skillId ? undefined : s.installedAt,
}));
// 持久化安装列表
localStorage.setItem(INSTALLED_KEY, JSON.stringify(newInstalledSkills));
set({
skills: updatedSkills,
installedSkills: newInstalledSkills,
});
return true;
} catch (err) {
set({
error: err instanceof Error ? err.message : '卸载技能失败',
});
return false;
}
},
// === 获取技能详情 ===
getSkillDetails: async (skillId: string) => {
const { skills } = get();
return skills.find(s => s.id === skillId) || null;
},
// === 加载评论 ===
loadReviews: async (skillId: string) => {
// MVP: 从 localStorage 模拟加载评论
const reviewsKey = `zclaw-skill-reviews-${skillId}`;
const stored = localStorage.getItem(reviewsKey);
const reviews: SkillReview[] = stored ? JSON.parse(stored) : [];
return reviews;
},
// === 添加评论 ===
addReview: async (skillId: string, review: Omit<SkillReview, 'id' | 'skillId' | 'createdAt'>) => {
const reviews = await get().loadReviews(skillId);
const newReview: SkillReview = {
...review,
id: `review-${Date.now()}`,
skillId,
createdAt: new Date().toISOString(),
};
const updatedReviews = [...reviews, newReview];
// 更新技能的评分和评论数
const { skills } = get();
const updatedSkills = skills.map(s => {
if (s.id === skillId) {
const totalRating = updatedReviews.reduce((sum, r) => sum + r.rating, 0);
const avgRating = totalRating / updatedReviews.length;
return {
...s,
rating: Math.round(avgRating * 10) / 10,
reviewCount: updatedReviews.length,
};
}
return s;
});
// 持久化评论
const reviewsKey = `zclaw-skill-reviews-${skillId}`;
localStorage.setItem(reviewsKey, JSON.stringify(updatedReviews));
set({ skills: updatedSkills });
},
// === 刷新技能列表 ===
refreshSkills: async () => {
// 清除缓存并重新加载
localStorage.removeItem(STORAGE_KEY);
await get().loadSkills();
},
// === 清除错误 ===
clearError: () => {
set({ error: null });
},
// === 重置状态 ===
reset: () => {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(INSTALLED_KEY);
set(initialState);
},
}
);
// === 辅助函数 ===
/**
* 扫描 skills 目录获取可用技能
*/
async function scanSkillsDirectory(): Promise<Skill[]> {
// 这里我们模拟扫描,实际实现需要通过 Tauri API 访问文件系统
// 或者从预定义的技能列表中加载
const skills: Skill[] = [
// 开发类
{
id: 'code-review',
name: '代码审查',
description: '审查代码、分析代码质量、提供改进建议',
triggers: ['审查代码', '代码审查', 'code review', 'PR Review', '检查代码', '分析代码'],
capabilities: ['代码质量分析', '架构评估', '最佳实践检查', '安全审计'],
toolDeps: ['read', 'grep', 'glob'],
category: 'development',
installed: false,
tags: ['代码', '审查', '质量'],
},
{
id: 'translation',
name: '翻译助手',
description: '翻译文本、多语言转换、保持语言风格一致性',
triggers: ['翻译', 'translate', '中译英', '英译中', '翻译成', '转换成'],
capabilities: ['多语言翻译', '技术文档翻译', '代码注释翻译', 'UI 文本翻译', '风格保持'],
toolDeps: ['read', 'write'],
category: 'content',
installed: false,
tags: ['翻译', '语言', '国际化'],
},
{
id: 'chinese-writing',
name: '中文写作',
description: '中文写作助手 - 帮助撰写各类中文文档、文章、报告',
triggers: ['写一篇', '帮我写', '撰写', '起草', '润色', '中文写作'],
capabilities: ['撰写文档', '润色修改', '调整语气', '中英文翻译'],
toolDeps: ['read', 'write'],
category: 'content',
installed: false,
tags: ['写作', '文档', '中文'],
},
{
id: 'web-search',
name: '网络搜索',
description: '搜索互联网信息、整合多方来源',
triggers: ['搜索', 'search', '查找信息', '查询', '搜索网络'],
capabilities: ['搜索引擎集成', '信息提取', '来源验证', '结果整合'],
toolDeps: ['web_search'],
category: 'research',
installed: false,
tags: ['搜索', '互联网', '信息'],
},
{
id: 'data-analysis',
name: '数据分析',
description: '数据清洗、统计分析、可视化图表',
triggers: ['数据分析', '统计', '可视化', '图表', 'analytics'],
capabilities: ['数据清洗', '统计分析', '可视化图表', '报告生成'],
toolDeps: ['read', 'write', 'shell'],
category: 'analytics',
installed: false,
tags: ['数据', '分析', '可视化'],
},
{
id: 'git',
name: 'Git 操作',
description: 'Git 版本控制操作、分支管理、冲突解决',
triggers: ['git', '版本控制', '分支', '合并', 'commit', 'merge'],
capabilities: ['分支管理', '冲突解决', 'rebase', 'cherry-pick'],
toolDeps: ['shell'],
category: 'development',
installed: false,
tags: ['git', '版本控制', '分支'],
},
{
id: 'shell-command',
name: 'Shell 命令',
description: '执行 Shell 命令、系统操作',
triggers: ['shell', '命令行', '终端', 'terminal', 'bash'],
capabilities: ['命令执行', '管道操作', '脚本运行', '环境管理'],
toolDeps: ['shell'],
category: 'ops',
installed: false,
tags: ['shell', '命令', '系统'],
},
{
id: 'file-operations',
name: '文件操作',
description: '文件读写、目录管理、文件搜索',
triggers: ['文件', 'file', '读取', '写入', '目录', '文件夹'],
capabilities: ['文件读写', '目录管理', '文件搜索', '批量操作'],
toolDeps: ['read', 'write', 'glob'],
category: 'ops',
installed: false,
tags: ['文件', '目录', '读写'],
},
{
id: 'security-engineer',
name: '安全工程师',
description: '安全工程师 - 负责安全审计、漏洞检测、合规检查',
triggers: ['安全审计', '漏洞检测', '安全检查', 'security', '渗透测试'],
capabilities: ['漏洞扫描', '合规检查', '安全加固', '威胁建模'],
toolDeps: ['read', 'grep', 'shell'],
category: 'security',
installed: false,
tags: ['安全', '审计', '漏洞'],
},
{
id: 'ai-engineer',
name: 'AI 工程师',
description: 'AI/ML 工程师 - 专注机器学习模型开发、LLM 集成和生产系统部署',
triggers: ['AI工程师', '机器学习', 'ML模型', 'LLM集成', '深度学习', '模型训练'],
capabilities: ['ML 框架', 'LLM 集成', 'RAG 系统', '向量数据库'],
toolDeps: ['bash', 'read', 'write', 'grep', 'glob'],
category: 'development',
installed: false,
tags: ['AI', 'ML', 'LLM'],
},
{
id: 'senior-developer',
name: '高级开发',
description: '高级开发工程师 - 端到端功能实现、复杂问题解决',
triggers: ['高级开发', 'senior developer', '端到端', '复杂功能', '架构实现'],
capabilities: ['端到端实现', '架构设计', '性能优化', '代码重构'],
toolDeps: ['bash', 'read', 'write', 'grep', 'glob'],
category: 'development',
installed: false,
tags: ['开发', '架构', '实现'],
},
{
id: 'frontend-developer',
name: '前端开发',
description: '前端开发专家 - 擅长 React/Vue/CSS/TypeScript',
triggers: ['前端开发', '页面开发', 'UI开发', 'React', 'Vue', 'CSS'],
capabilities: ['组件开发', '样式调整', '性能优化', '响应式设计'],
toolDeps: ['read', 'write', 'shell'],
category: 'development',
installed: false,
types: ['前端', 'UI', '组件'],
},
{
id: 'backend-architect',
name: '后端架构',
description: '后端架构设计、API设计、数据库建模',
triggers: ['后端架构', 'API设计', '数据库设计', '系统架构', '微服务'],
capabilities: ['架构设计', 'API规范', '数据库建模', '性能优化'],
toolDeps: ['read', 'write', 'shell'],
category: 'development',
installed: false,
tags: ['后端', '架构', 'API'],
},
{
id: 'devops-automator',
name: 'DevOps 自动化',
description: 'CI/CD、Docker、K8s、自动化部署',
triggers: ['DevOps', 'CI/CD', 'Docker', '部署', '自动化', 'K8s'],
capabilities: ['CI/CD配置', '容器化', '自动化部署', '监控告警'],
toolDeps: ['shell', 'read', 'write'],
category: 'ops',
installed: false,
tags: ['DevOps', 'Docker', 'CI/CD'],
},
{
id: 'senior-pm',
name: '高级PM',
description: '项目管理、需求分析、迭代规划',
triggers: ['项目管理', '需求分析', '迭代规划', '产品设计', 'PRD'],
capabilities: ['需求拆解', '迭代排期', '风险评估', '文档撰写'],
toolDeps: ['read', 'write'],
category: 'management',
installed: false,
tags: ['PM', '需求', '迭代'],
},
];
return skills;
}

View File

@@ -8,6 +8,7 @@
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
Team,
TeamMember,
@@ -120,7 +121,9 @@ const calculateMetrics = (team: Team): TeamMetrics => {
// === Store Implementation ===
export const useTeamStore = create<TeamStoreState>((set, get) => ({
export const useTeamStore = create<TeamStoreState>()(
persist(
(set, get) => ({
// Initial State
teams: [],
activeTeam: null,
@@ -582,4 +585,12 @@ export const useTeamStore = create<TeamStoreState>((set, get) => ({
clearError: () => {
set({ error: null });
},
}));
}),
{
name: 'zclaw-teams',
partialize: (state) => ({
teams: state.teams,
activeTeam: state.activeTeam,
}),
},
));

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { Workflow, WorkflowRun } from './gatewayStore';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
@@ -30,7 +31,7 @@ export interface WorkflowStep {
condition?: string;
}
export interface CreateWorkflowInput {
export interface WorkflowCreateOptions {
name: string;
description?: string;
steps: WorkflowStep[];
@@ -53,7 +54,7 @@ export interface ExtendedWorkflowRun extends WorkflowRun {
interface WorkflowClient {
listWorkflows(): Promise<{ workflows: { id: string; name: string; steps: number; description?: string; createdAt?: string }[] } | null>;
createWorkflow(workflow: CreateWorkflowInput): Promise<{ id: string; name: string } | null>;
createWorkflow(workflow: WorkflowCreateOptions): Promise<{ id: string; name: string } | null>;
updateWorkflow(id: string, updates: UpdateWorkflowInput): Promise<{ id: string; name: string } | null>;
deleteWorkflow(id: string): Promise<{ status: string }>;
executeWorkflow(id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string } | null>;
@@ -61,9 +62,9 @@ interface WorkflowClient {
listWorkflowRuns(workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: RawWorkflowRun[] } | null>;
}
// === Store State ===
// === Store State Slice ===
interface WorkflowState {
export interface WorkflowStateSlice {
workflows: Workflow[];
workflowRuns: Record<string, ExtendedWorkflowRun[]>;
isLoading: boolean;
@@ -71,13 +72,13 @@ interface WorkflowState {
client: WorkflowClient;
}
// === Store Actions ===
// === Store Actions Slice ===
interface WorkflowActions {
export interface WorkflowActionsSlice {
setWorkflowStoreClient: (client: WorkflowClient) => void;
loadWorkflows: () => Promise<void>;
getWorkflow: (id: string) => Workflow | undefined;
createWorkflow: (workflow: CreateWorkflowInput) => Promise<Workflow | undefined>;
createWorkflow: (workflow: WorkflowCreateOptions) => Promise<Workflow | undefined>;
updateWorkflow: (id: string, updates: UpdateWorkflowInput) => Promise<Workflow | undefined>;
deleteWorkflow: (id: string) => Promise<void>;
triggerWorkflow: (id: string, input?: Record<string, unknown>) => Promise<{ runId: string; status: string } | undefined>;
@@ -87,6 +88,10 @@ interface WorkflowActions {
reset: () => void;
}
// === Combined Store Type ===
export type WorkflowStore = WorkflowStateSlice & WorkflowActionsSlice;
// === Initial State ===
const initialState = {
@@ -99,7 +104,7 @@ const initialState = {
// === Store ===
export const useWorkflowStore = create<WorkflowState & WorkflowActions>((set, get) => ({
export const useWorkflowStore = create<WorkflowStateSlice & WorkflowActionsSlice>((set, get) => ({
...initialState,
setWorkflowStoreClient: (client: WorkflowClient) => {
@@ -128,7 +133,7 @@ export const useWorkflowStore = create<WorkflowState & WorkflowActions>((set, ge
return get().workflows.find(w => w.id === id);
},
createWorkflow: async (workflow: CreateWorkflowInput) => {
createWorkflow: async (workflow: WorkflowCreateOptions) => {
set({ error: null });
try {
const result = await get().client.createWorkflow(workflow);
@@ -253,3 +258,29 @@ export const useWorkflowStore = create<WorkflowState & WorkflowActions>((set, ge
// Re-export types from gatewayStore for convenience
export type { Workflow, WorkflowRun };
// === Client Injection ===
/**
* Helper to create a WorkflowClient adapter from a GatewayClient.
*/
function createWorkflowClientFromGateway(client: GatewayClient): WorkflowClient {
return {
listWorkflows: () => client.listWorkflows(),
createWorkflow: (workflow) => client.createWorkflow(workflow),
updateWorkflow: (id, updates) => client.updateWorkflow(id, updates),
deleteWorkflow: (id) => client.deleteWorkflow(id),
executeWorkflow: (id, input) => client.executeWorkflow(id, input),
cancelWorkflow: (workflowId, runId) => client.cancelWorkflow(workflowId, runId),
listWorkflowRuns: (workflowId, opts) => client.listWorkflowRuns(workflowId, opts),
};
}
/**
* Sets the client for the workflow store.
* Called by the coordinator during initialization.
*/
export function setWorkflowStoreClient(client: unknown): void {
const workflowClient = createWorkflowClientFromGateway(client as GatewayClient);
useWorkflowStore.getState().setWorkflowStoreClient(workflowClient);
}

View File

@@ -0,0 +1,87 @@
/**
* 主动学习引擎类型定义
*
* 定义学习事件、模式、建议等核心类型。
*/
// === 学习事件类型 ===
export type LearningEventType =
| 'preference' // 偏好学习
| 'correction' // 纠正学习
| 'context' // 上下文学习
| 'feedback' // 反馈学习
| 'behavior' // 行为学习
| 'implicit'; // 隐式学习
export type FeedbackSentiment = 'positive' | 'negative' | 'neutral';
// === 学习事件 ===
export interface LearningEvent {
id: string;
type: LearningEventType;
agentId: string;
conversationId?: string;
messageId?: string;
// 事件内容
trigger: string; // 触发学习的原始内容
observation: string; // 观察到的用户行为/反馈
context?: string; // 上下文信息
// 学习结果
inferredPreference?: string;
inferredRule?: string;
confidence: number; // 0-1
// 元数据
timestamp: number;
updatedAt?: number;
acknowledged: boolean;
appliedCount: number;
}
// === 学习模式 ===
export interface LearningPattern {
type: 'preference' | 'rule' | 'context' | 'behavior';
pattern: string;
description: string;
examples: string[];
confidence: number;
agentId: string;
updatedAt?: number;
}
// === 学习建议 ===
export interface LearningSuggestion {
id: string;
agentId: string;
type: string;
pattern: string;
suggestion: string;
confidence: number;
createdAt: number;
expiresAt: Date;
dismissed: boolean;
}
// === 学习配置 ===
export interface LearningConfig {
enabled: boolean;
minConfidence: number;
maxEvents: number;
suggestionCooldown: number;
}
// === 默认配置 ===
export const DEFAULT_LEARNING_CONFIG: LearningConfig = {
enabled: true,
minConfidence: 0.5,
maxEvents: 1000,
suggestionCooldown: 2, // hours
};

View File

@@ -36,8 +36,6 @@ export interface Skill {
installedAt?: string;
}
}
// 技能评论
export interface SkillReview {
/** 评论ID */
@@ -54,8 +52,6 @@ export interface SkillReview {
createdAt: string;
}
}
// 技能市场状态
export interface SkillMarketState {
/** 所有技能 */