Files
zclaw_openfang/desktop/src/components/ReflectionLog.tsx
iven 5c8b1b53ce feat(intelligence): add reflection config persistence and proactive personality suggestions
Config Persistence:
- Save reflection config to localStorage
- Load config on startup with fallback defaults
- Auto-sync config changes to backend

Proactive Personality Suggestions (P2):
- Add check_personality_improvement to heartbeat engine
- Detects user correction patterns (啰嗦/简洁, etc.)
- Add check_learning_opportunities to heartbeat engine
- Identifies learning opportunities from conversations
- Both checks generate HeartbeatAlert when thresholds met

These enhancements complete the self-evolution capability chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 01:08:24 +08:00

673 lines
25 KiB
TypeScript

/**
* 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,
Check,
X,
ChevronDown,
ChevronRight,
RefreshCw,
AlertTriangle,
TrendingUp,
TrendingDown,
Minus,
FileText,
History,
Play,
Settings,
} from 'lucide-react';
import {
intelligenceClient,
type ReflectionResult,
type IdentityChangeProposal,
type ReflectionConfig,
type PatternObservation,
type ImprovementSuggestion,
} from '../lib/intelligence-client';
// === Config Persistence ===
const REFLECTION_CONFIG_KEY = 'zclaw-reflection-config';
const DEFAULT_CONFIG: ReflectionConfig = {
trigger_after_conversations: 5,
allow_soul_modification: true,
require_approval: true,
use_llm: true,
llm_fallback_to_rules: true,
};
function loadConfig(): ReflectionConfig {
try {
const stored = localStorage.getItem(REFLECTION_CONFIG_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_CONFIG, ...parsed };
}
} catch {
console.warn('[ReflectionLog] Failed to load config from localStorage');
}
return DEFAULT_CONFIG;
}
function saveConfig(config: ReflectionConfig): void {
try {
localStorage.setItem(REFLECTION_CONFIG_KEY, JSON.stringify(config));
} catch {
console.warn('[ReflectionLog] Failed to save config to localStorage');
}
}
// === 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 fileName = proposal.file.split('/').pop() || proposal.file;
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.current_content.slice(0, 500)}
{proposal.current_content.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.suggested_content.slice(0, 500)}
{proposal.suggested_content.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;
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.identity_proposals.length > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{result.identity_proposals.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.new_memories}</span>
<span>: {result.identity_proposals.length}</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// === Main Component ===
export function ReflectionLog({
className = '',
agentId = 'zclaw-main',
onProposalApprove,
onProposalReject,
}: ReflectionLogProps) {
const [history, setHistory] = useState<ReflectionResult[]>([]);
const [pendingProposals, setPendingProposals] = useState<IdentityChangeProposal[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [isReflecting, setIsReflecting] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [config, setConfig] = useState<ReflectionConfig>(() => loadConfig());
// Persist config changes
useEffect(() => {
saveConfig(config);
}, [config]);
// Load history and pending proposals
useEffect(() => {
const loadData = async () => {
try {
// Initialize reflection engine with config that allows soul modification
await intelligenceClient.reflection.init(config);
const loadedHistory = await intelligenceClient.reflection.getHistory();
setHistory([...loadedHistory].reverse()); // Most recent first
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
setPendingProposals(proposals);
} catch (error) {
console.error('[ReflectionLog] Failed to load data:', error);
}
};
loadData();
}, [agentId, config]);
const handleReflect = useCallback(async () => {
setIsReflecting(true);
try {
const result = await intelligenceClient.reflection.reflect(agentId, []);
setHistory((prev) => [result, ...prev]);
// Convert reflection identity_proposals to actual identity proposals
// The reflection result contains proposals that need to be persisted
if (result.identity_proposals && result.identity_proposals.length > 0) {
for (const proposal of result.identity_proposals) {
try {
// Determine which file to modify based on the field
const file: 'soul' | 'instructions' =
proposal.field === 'soul' || proposal.field === 'instructions'
? (proposal.field as 'soul' | 'instructions')
: proposal.field.toLowerCase().includes('soul')
? 'soul'
: 'instructions';
// Persist the proposal to the identity system
await intelligenceClient.identity.proposeChange(
agentId,
file,
proposal.proposed_value,
proposal.reason
);
} catch (err) {
console.warn('[ReflectionLog] Failed to create identity proposal:', err);
}
}
// Refresh pending proposals from the identity system
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
setPendingProposals(proposals);
}
} catch (error) {
console.error('[ReflectionLog] Reflection failed:', error);
} finally {
setIsReflecting(false);
}
}, [agentId]);
const handleApproveProposal = useCallback(
async (proposal: IdentityChangeProposal) => {
await intelligenceClient.identity.approveProposal(proposal.id);
setPendingProposals((prev: IdentityChangeProposal[]) => prev.filter((p: IdentityChangeProposal) => p.id !== proposal.id));
onProposalApprove?.(proposal);
},
[onProposalApprove]
);
const handleRejectProposal = useCallback(
async (proposal: IdentityChangeProposal) => {
await intelligenceClient.identity.rejectProposal(proposal.id);
setPendingProposals((prev: IdentityChangeProposal[]) => prev.filter((p: IdentityChangeProposal) => p.id !== proposal.id));
onProposalReject?.(proposal);
},
[onProposalReject]
);
const stats = useMemo(() => {
const totalReflections = history.length;
const totalPatterns = history.reduce((sum: number, r: ReflectionResult) => sum + r.patterns.length, 0);
const totalImprovements = history.reduce((sum: number, r: ReflectionResult) => sum + r.improvements.length, 0);
const totalIdentityChanges = history.reduce((sum: number, r: ReflectionResult) => sum + r.identity_proposals.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.trigger_after_conversations || 5}
onChange={(e) =>
setConfig((prev) => ({ ...prev, trigger_after_conversations: 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, allow_soul_modification: !prev.allow_soul_modification }))}
className={`relative w-9 h-5 rounded-full transition-colors ${
config.allow_soul_modification ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<motion.div
animate={{ x: config.allow_soul_modification ? 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, require_approval: !prev.require_approval }))}
className={`relative w-9 h-5 rounded-full transition-colors ${
config.require_approval ? 'bg-purple-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<motion.div
animate={{ x: config.require_approval ? 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) => (
<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;