feat(l4): add Phase 1 UI components for self-evolution capability

SwarmDashboard (多 Agent 协作面板):
- Task list with real-time status updates
- Subtask visualization with results
- Communication style indicators (Sequential/Parallel/Debate)
- Task creation form with manual triggers

SkillMarket (技能市场):
- Browse 12 built-in skills by category
- Keyword/capability search
- Skill details with triggers and capabilities
- Install/uninstall with L4 autonomy hooks

HeartbeatConfig (心跳配置):
- Enable/disable periodic proactive checks
- Interval slider (5-120 minutes)
- Proactivity level selector (Silent/Light/Standard/Autonomous)
- Quiet hours configuration
- Built-in check item toggles

ReflectionLog (反思日志):
- Reflection history with pattern analysis
- Improvement suggestions by priority
- Identity change proposal approval workflow
- Manual reflection trigger
- Config panel for trigger settings

Part of ZCLAW L4 Self-Evolution capability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-16 10:24:00 +08:00
parent 721e400bd0
commit 85e39ecafd
4 changed files with 2199 additions and 0 deletions

View File

@@ -0,0 +1,608 @@
/**
* ReflectionLog - Self-reflection history and identity change approval UI
*
* Displays:
* - Reflection history (patterns, improvements)
* - Pending identity change proposals
* - Approve/reject identity modifications
* - Manual reflection trigger
*
* Part of ZCLAW L4 Self-Evolution capability.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Brain,
Sparkles,
Check,
X,
Clock,
ChevronDown,
ChevronRight,
RefreshCw,
AlertTriangle,
TrendingUp,
TrendingDown,
Minus,
FileText,
History,
Play,
Settings,
} from 'lucide-react';
import {
ReflectionEngine,
type ReflectionResult,
type PatternObservation,
type ImprovementSuggestion,
type ReflectionConfig,
DEFAULT_REFLECTION_CONFIG,
} from '../lib/reflection-engine';
import { getAgentIdentityManager, type IdentityChangeProposal } from '../lib/agent-identity';
// === Types ===
interface ReflectionLogProps {
className?: string;
agentId?: string;
onProposalApprove?: (proposal: IdentityChangeProposal) => void;
onProposalReject?: (proposal: IdentityChangeProposal) => void;
}
// === Sentiment Config ===
const SENTIMENT_CONFIG: Record<string, { icon: typeof TrendingUp; color: string; bgColor: string }> = {
positive: {
icon: TrendingUp,
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30',
},
negative: {
icon: TrendingDown,
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-100 dark:bg-red-900/30',
},
neutral: {
icon: Minus,
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-100 dark:bg-gray-800',
},
};
// === Priority Config ===
const PRIORITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
high: {
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-100 dark:bg-red-900/30',
},
medium: {
color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
},
low: {
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
},
};
// === Components ===
function SentimentBadge({ sentiment }: { sentiment: string }) {
const config = SENTIMENT_CONFIG[sentiment] || SENTIMENT_CONFIG.neutral;
const Icon = config.icon;
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}
>
<Icon className="w-3 h-3" />
{sentiment === 'positive' ? '积极' : sentiment === 'negative' ? '消极' : '中性'}
</span>
);
}
function PriorityBadge({ priority }: { priority: string }) {
const config = PRIORITY_CONFIG[priority] || PRIORITY_CONFIG.medium;
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bgColor} ${config.color}`}
>
{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}
</span>
);
}
function PatternCard({ pattern }: { pattern: PatternObservation }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors text-left"
>
<SentimentBadge sentiment={pattern.sentiment} />
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 dark:text-gray-100">{pattern.observation}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
: {pattern.frequency}
</p>
</div>
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
)}
</button>
<AnimatePresence>
{expanded && pattern.evidence.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/30"
>
<h5 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2"></h5>
<ul className="space-y-1">
{pattern.evidence.map((ev, i) => (
<li key={i} className="text-xs text-gray-600 dark:text-gray-300 pl-2 border-l-2 border-gray-300 dark:border-gray-600">
{ev}
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function ImprovementCard({ improvement }: { improvement: ImprovementSuggestion }) {
return (
<div className="flex items-start gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{improvement.area}
</span>
<PriorityBadge priority={improvement.priority} />
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">{improvement.suggestion}</p>
</div>
</div>
);
}
function ProposalCard({
proposal,
onApprove,
onReject,
}: {
proposal: IdentityChangeProposal;
onApprove: () => void;
onReject: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const identityManager = getAgentIdentityManager();
const fileName = proposal.filePath.split('/').pop() || proposal.filePath;
const fileType = fileName.toLowerCase().replace('.md', '').toUpperCase();
return (
<div className="border border-yellow-300 dark:border-yellow-700 rounded-lg overflow-hidden bg-yellow-50 dark:bg-yellow-900/20">
<div className="flex items-center gap-3 p-3">
<div className="w-8 h-8 rounded-lg bg-yellow-100 dark:bg-yellow-800 flex items-center justify-center">
<FileText className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{fileType}
</span>
<span className="px-1.5 py-0.5 text-xs bg-yellow-200 dark:bg-yellow-800 text-yellow-700 dark:text-yellow-300 rounded">
</span>
</div>
<p className="text-xs text-yellow-600 dark:text-yellow-400 truncate">
{proposal.reason}
</p>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="p-1 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-100 dark:hover:bg-yellow-800 rounded"
>
{expanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-yellow-200 dark:border-yellow-700"
>
<div className="p-3 space-y-3">
<div>
<h5 className="text-xs font-medium text-yellow-700 dark:text-yellow-300 mb-1">
</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.currentContent.slice(0, 500)}
{proposal.currentContent.length > 500 && '...'}
</pre>
</div>
<div>
<h5 className="text-xs font-medium text-yellow-700 dark:text-yellow-300 mb-1">
</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 && '...'}
</pre>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-end gap-2 p-3 border-t border-yellow-200 dark:border-yellow-700">
<button
onClick={onReject}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
<button
onClick={onApprove}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
</button>
</div>
</div>
);
}
function ReflectionEntry({
result,
isExpanded,
onToggle,
}: {
result: ReflectionResult;
isExpanded: boolean;
onToggle: () => void;
}) {
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">
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors text-left"
>
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Brain className="w-5 h-5 text-purple-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(result.timestamp).toLocaleString('zh-CN')}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="text-green-600 dark:text-green-400">
{positivePatterns}
</span>
<span className="text-red-600 dark:text-red-400">
{negativePatterns}
</span>
<span className="text-gray-500 dark:text-gray-400">
{result.improvements.length}
</span>
{result.identityProposals.length > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{result.identityProposals.length}
</span>
)}
</div>
</div>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-gray-200 dark:border-gray-700"
>
<div className="p-4 space-y-4">
{/* Patterns */}
{result.patterns.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
</h4>
<div className="space-y-2">
{result.patterns.map((pattern, i) => (
<PatternCard key={i} pattern={pattern} />
))}
</div>
</div>
)}
{/* Improvements */}
{result.improvements.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
</h4>
<div className="space-y-2">
{result.improvements.map((improvement, i) => (
<ImprovementCard key={i} improvement={improvement} />
))}
</div>
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400 pt-2 border-t border-gray-200 dark:border-gray-700">
<span>: {result.newMemories}</span>
<span>: {result.identityProposals.length}</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// === Main Component ===
export function ReflectionLog({
className = '',
agentId = 'zclaw-main',
onProposalApprove,
onProposalReject,
}: ReflectionLogProps) {
const [engine] = useState(() => new ReflectionEngine());
const [history, setHistory] = useState<ReflectionResult[]>([]);
const [pendingProposals, setPendingProposals] = useState<IdentityChangeProposal[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isReflecting, setIsReflecting] = useState(false);
const [config, setConfig] = useState<ReflectionConfig>(DEFAULT_REFLECTION_CONFIG);
const [showConfig, setShowConfig] = useState(false);
// Load history and pending proposals
useEffect(() => {
const loadedHistory = engine.getHistory();
setHistory([...loadedHistory].reverse()); // Most recent first
const identityManager = getAgentIdentityManager();
const proposals = identityManager.getPendingProposals(agentId);
setPendingProposals(proposals);
}, [engine, agentId]);
const handleReflect = useCallback(async () => {
setIsReflecting(true);
try {
const result = await engine.reflect(agentId);
setHistory((prev) => [result, ...prev]);
// Update pending proposals
if (result.identityProposals.length > 0) {
setPendingProposals((prev) => [...prev, ...result.identityProposals]);
}
} catch (error) {
console.error('[ReflectionLog] Reflection failed:', error);
} finally {
setIsReflecting(false);
}
}, [engine, agentId]);
const handleApproveProposal = useCallback(
(proposal: IdentityChangeProposal) => {
const identityManager = getAgentIdentityManager();
identityManager.approveChange(proposal.id);
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
onProposalApprove?.(proposal);
},
[onProposalApprove]
);
const handleRejectProposal = useCallback(
(proposal: IdentityChangeProposal) => {
const identityManager = getAgentIdentityManager();
identityManager.rejectChange(proposal.id);
setPendingProposals((prev) => prev.filter((p) => p.id !== proposal.id));
onProposalReject?.(proposal);
},
[onProposalReject]
);
const stats = useMemo(() => {
const totalReflections = history.length;
const totalPatterns = history.reduce((sum, r) => sum + r.patterns.length, 0);
const totalImprovements = history.reduce((sum, r) => sum + r.improvements.length, 0);
const totalIdentityChanges = history.reduce((sum, r) => sum + r.identityProposals.length, 0);
return { totalReflections, totalPatterns, totalImprovements, totalIdentityChanges };
}, [history]);
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Brain className="w-5 h-5 text-purple-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConfig(!showConfig)}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="配置"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={handleReflect}
disabled={isReflecting}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-500 hover:bg-purple-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isReflecting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Stats Bar */}
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 text-xs">
<span className="text-gray-500 dark:text-gray-400">
: <span className="font-medium text-gray-900 dark:text-gray-100">{stats.totalReflections}</span>
</span>
<span className="text-purple-600 dark:text-purple-400">
: <span className="font-medium">{stats.totalPatterns}</span>
</span>
<span className="text-blue-600 dark:text-blue-400">
: <span className="font-medium">{stats.totalImprovements}</span>
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: <span className="font-medium">{stats.totalIdentityChanges}</span>
</span>
</div>
{/* Config Panel */}
<AnimatePresence>
{showConfig && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-b border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300"></span>
<input
type="number"
min="1"
max="20"
value={config.triggerAfterConversations}
onChange={(e) =>
setConfig((prev) => ({ ...prev, triggerAfterConversations: parseInt(e.target.value) || 5 }))
}
className="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300"> SOUL.md</span>
<button
onClick={() => setConfig((prev) => ({ ...prev, allowSoulModification: !prev.allowSoulModification }))}
className={`relative w-9 h-5 rounded-full transition-colors ${
config.allowSoulModification ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<motion.div
animate={{ x: config.allowSoulModification ? 18 : 0 }}
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
/>
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300"></span>
<button
onClick={() => setConfig((prev) => ({ ...prev, requireApproval: !prev.requireApproval }))}
className={`relative w-9 h-5 rounded-full transition-colors ${
config.requireApproval ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<motion.div
animate={{ x: config.requireApproval ? 18 : 0 }}
className="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow"
/>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Pending Proposals */}
{pendingProposals.length > 0 && (
<div className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-medium text-yellow-700 dark:text-yellow-300">
<AlertTriangle className="w-4 h-4" />
({pendingProposals.length})
</h3>
{pendingProposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
onApprove={() => handleApproveProposal(proposal)}
onReject={() => handleRejectProposal(proposal)}
/>
))}
</div>
)}
{/* History */}
<div className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400">
<History className="w-4 h-4" />
</h3>
{history.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<Brain className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm"></p>
<button
onClick={handleReflect}
className="mt-2 text-purple-500 hover:text-purple-600 text-sm"
>
</button>
</div>
) : (
history.map((result, i) => (
<ReflectionEntry
key={result.timestamp}
result={result}
isExpanded={expandedId === result.timestamp}
onToggle={() => setExpandedId((prev) => (prev === result.timestamp ? null : result.timestamp))}
/>
))
)}
</div>
</div>
</div>
);
}
export default ReflectionLog;