feat(intelligence): add self-evolution UI for identity change proposals

P1.1: Identity Change Proposal UI
- Create IdentityChangeProposal.tsx with diff view for SOUL.md changes
- Add approve/reject buttons with visual feedback
- Show evolution history timeline with restore capability

P1.2: Connect Reflection Engine to Identity Proposals
- Update ReflectionLog.tsx to convert reflection proposals to identity proposals
- Add ReflectionIdentityProposal type for non-persisted proposals
- Auto-create identity proposals when reflection detects personality changes

P1.3: Evolution History and Rollback
- Display identity snapshots with timestamps
- One-click restore to previous personality versions
- Visual diff between current and proposed content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-24 00:51:03 +08:00
parent 6c64d704d7
commit bfad61c3da
5 changed files with 526 additions and 6 deletions

View File

@@ -0,0 +1,476 @@
/**
* Identity Change Proposal Component
*
* Displays pending personality change proposals with:
* - Side-by-side diff view
* - Accept/Reject buttons
* - Reason explanation
*
* Part of ZCLAW L4 Self-Evolution capability.
*/
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Check,
X,
FileText,
Clock,
AlertCircle,
ChevronDown,
ChevronUp,
Sparkles,
History,
} from 'lucide-react';
import {
intelligenceClient,
type IdentityChangeProposal as Proposal,
type IdentitySnapshot,
} from '../lib/intelligence-client';
import { useChatStore } from '../store/chatStore';
import { Button, Badge } from './ui';
// === Diff View Component ===
function DiffView({
current,
proposed,
}: {
current: string;
proposed: string;
}) {
const currentLines = useMemo(() => current.split('\n'), [current]);
const proposedLines = useMemo(() => proposed.split('\n'), [proposed]);
// Simple line-by-line diff
const maxLines = Math.max(currentLines.length, proposedLines.length);
const diffLines: Array<{
type: 'unchanged' | 'added' | 'removed' | 'modified';
current?: string;
proposed?: string;
lineNum: number;
}> = [];
// Build a simple diff - for a production system, use a proper diff algorithm
// Note: currentSet/proposedSet could be used for advanced diff algorithms
// const currentSet = new Set(currentLines);
// const proposedSet = new Set(proposedLines);
for (let i = 0; i < maxLines; i++) {
const currLine = currentLines[i];
const propLine = proposedLines[i];
if (currLine === propLine) {
diffLines.push({ type: 'unchanged', current: currLine, proposed: propLine, lineNum: i + 1 });
} else if (currLine === undefined) {
diffLines.push({ type: 'added', proposed: propLine, lineNum: i + 1 });
} else if (propLine === undefined) {
diffLines.push({ type: 'removed', current: currLine, lineNum: i + 1 });
} else {
diffLines.push({ type: 'modified', current: currLine, proposed: propLine, lineNum: i + 1 });
}
}
return (
<div className="grid grid-cols-2 gap-2 text-xs font-mono">
{/* Current */}
<div className="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-red-50 dark:bg-red-900/20 px-2 py-1 text-red-700 dark:text-red-300 font-sans font-medium border-b border-red-100 dark:border-red-800">
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-64 overflow-y-auto">
{diffLines.map((line, idx) => (
<div
key={idx}
className={`px-2 py-0.5 ${
line.type === 'removed'
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
: line.type === 'modified'
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{line.type === 'removed' && <span className="text-red-500 mr-1">-</span>}
{line.type === 'modified' && <span className="text-yellow-500 mr-1">~</span>}
{line.current || '\u00A0'}
</div>
))}
</div>
</div>
{/* Proposed */}
<div className="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-green-50 dark:bg-green-900/20 px-2 py-1 text-green-700 dark:text-green-300 font-sans font-medium border-b border-green-100 dark:border-green-800">
</div>
<div className="bg-gray-50 dark:bg-gray-800/50 max-h-64 overflow-y-auto">
{diffLines.map((line, idx) => (
<div
key={idx}
className={`px-2 py-0.5 ${
line.type === 'added'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: line.type === 'modified'
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{line.type === 'added' && <span className="text-green-500 mr-1">+</span>}
{line.type === 'modified' && <span className="text-yellow-500 mr-1">~</span>}
{line.proposed || '\u00A0'}
</div>
))}
</div>
</div>
</div>
);
}
// === Single Proposal Card ===
function ProposalCard({
proposal,
onApprove,
onReject,
isProcessing,
}: {
proposal: Proposal;
onApprove: () => void;
onReject: () => void;
isProcessing: boolean;
}) {
const [expanded, setExpanded] = useState(true);
const fileLabel = proposal.file === 'soul' ? 'SOUL.md' : 'Instructions';
const timeAgo = getTimeAgo(proposal.created_at);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="rounded-xl border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-900/10 overflow-hidden"
>
{/* Header */}
<div
className="px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-orange-100/50 dark:hover:bg-orange-900/20 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-orange-600 dark:text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
</span>
<Badge variant="warning" className="text-xs">
{fileLabel}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<Clock className="w-3 h-3" />
<span>{timeAgo}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
{/* Content */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 space-y-4">
{/* Reason */}
<div className="rounded-lg bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-700">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">{proposal.reason}</p>
</div>
{/* Diff View */}
<DiffView
current={proposal.current_content}
proposed={proposal.suggested_content}
/>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={onReject}
disabled={isProcessing}
className="text-red-600 border-red-200 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>
<X className="w-4 h-4 mr-1" />
</Button>
<Button
variant="primary"
size="sm"
onClick={onApprove}
disabled={isProcessing}
className="bg-green-600 hover:bg-green-700"
>
<Check className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
// === Evolution History Item ===
function HistoryItem({
snapshot,
onRestore,
isRestoring,
}: {
snapshot: IdentitySnapshot;
onRestore: () => void;
isRestoring: boolean;
}) {
const timeAgo = getTimeAgo(snapshot.timestamp);
return (
<div className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700">
<div className="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
<History className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">{timeAgo}</span>
<Button
variant="ghost"
size="sm"
onClick={onRestore}
disabled={isRestoring}
className="text-xs text-gray-500 hover:text-orange-600"
>
</Button>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 truncate">
{snapshot.reason || '自动快照'}
</p>
</div>
</div>
);
}
// === Main Component ===
export function IdentityChangeProposalPanel() {
const { currentAgent } = useChatStore();
const [proposals, setProposals] = useState<Proposal[]>([]);
const [snapshots, setSnapshots] = useState<IdentitySnapshot[]>([]);
const [loading, setLoading] = useState(true);
const [processingId, setProcessingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const agentId = currentAgent?.id;
// Load data
useEffect(() => {
if (!agentId) return;
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [pendingProposals, agentSnapshots] = await Promise.all([
intelligenceClient.identity.getPendingProposals(agentId),
intelligenceClient.identity.getSnapshots(agentId, 10),
]);
setProposals(pendingProposals);
setSnapshots(agentSnapshots);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to load data:', err);
setError('加载失败');
} finally {
setLoading(false);
}
};
loadData();
}, [agentId]);
const handleApprove = async (proposalId: string) => {
if (!agentId) return;
setProcessingId(proposalId);
setError(null);
try {
await intelligenceClient.identity.approveProposal(proposalId);
// Refresh data
const [pendingProposals, agentSnapshots] = await Promise.all([
intelligenceClient.identity.getPendingProposals(agentId),
intelligenceClient.identity.getSnapshots(agentId, 10),
]);
setProposals(pendingProposals);
setSnapshots(agentSnapshots);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to approve:', err);
setError('审批失败');
} finally {
setProcessingId(null);
}
};
const handleReject = async (proposalId: string) => {
if (!agentId) return;
setProcessingId(proposalId);
setError(null);
try {
await intelligenceClient.identity.rejectProposal(proposalId);
// Refresh proposals
const pendingProposals = await intelligenceClient.identity.getPendingProposals(agentId);
setProposals(pendingProposals);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to reject:', err);
setError('拒绝失败');
} finally {
setProcessingId(null);
}
};
const handleRestore = async (snapshotId: string) => {
if (!agentId) return;
setProcessingId(snapshotId);
setError(null);
try {
await intelligenceClient.identity.restoreSnapshot(agentId, snapshotId);
// Refresh snapshots
const agentSnapshots = await intelligenceClient.identity.getSnapshots(agentId, 10);
setSnapshots(agentSnapshots);
} catch (err) {
console.error('[IdentityChangeProposal] Failed to restore:', err);
setError('恢复失败');
} finally {
setProcessingId(null);
}
};
if (!agentId) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"> Agent</p>
</div>
);
}
if (loading) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="animate-pulse">...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Pending Proposals */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-orange-500" />
{proposals.length > 0 && (
<Badge variant="warning" className="text-xs">
{proposals.length}
</Badge>
)}
</h3>
{proposals.length === 0 ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400 text-sm bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700">
</div>
) : (
<div className="space-y-3">
<AnimatePresence>
{proposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
onApprove={() => handleApprove(proposal.id)}
onReject={() => handleReject(proposal.id)}
isProcessing={processingId === proposal.id}
/>
))}
</AnimatePresence>
</div>
)}
</div>
{/* Evolution History */}
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2">
<History className="w-4 h-4 text-gray-500" />
</h3>
{snapshots.length === 0 ? (
<div className="text-center py-6 text-gray-500 dark:text-gray-400 text-sm bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700">
</div>
) : (
<div className="space-y-2">
{snapshots.map((snapshot) => (
<HistoryItem
key={snapshot.id}
snapshot={snapshot}
onRestore={() => handleRestore(snapshot.id)}
isRestoring={processingId === snapshot.id}
/>
))}
</div>
)}
</div>
</div>
);
}
// === Helper ===
function getTimeAgo(timestamp: string): string {
const now = Date.now();
const then = new Date(timestamp).getTime();
const diff = now - then;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
return new Date(timestamp).toLocaleDateString('zh-CN');
}
export default IdentityChangeProposalPanel;

View File

@@ -413,9 +413,34 @@ export function ReflectionLog({
const result = await intelligenceClient.reflection.reflect(agentId, []);
setHistory((prev) => [result, ...prev]);
// Update pending proposals
if (result.identity_proposals.length > 0) {
setPendingProposals((prev) => [...prev, ...result.identity_proposals]);
// 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);

View File

@@ -8,7 +8,7 @@ import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, Brain,
Shield, Sparkles, GraduationCap, List, Network
Shield, Sparkles, GraduationCap, List, Network, Dna
} from 'lucide-react';
// === Helper to extract code blocks from markdown content ===
@@ -74,6 +74,7 @@ import { MemoryGraph } from './MemoryGraph';
import { ReflectionLog } from './ReflectionLog';
import { AutonomyConfig } from './AutonomyConfig';
import { ActiveLearningPanel } from './ActiveLearningPanel';
import { IdentityChangeProposalPanel } from './IdentityChangeProposal';
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
import { cardHover, defaultTransition } from '../lib/animations';
import { Button, Badge } from './ui';
@@ -101,7 +102,7 @@ export function RightPanel() {
const quickConfig = useConfigStore((s) => s.quickConfig);
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning'>('status');
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'learning' | 'evolution'>('status');
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
@@ -263,6 +264,12 @@ export function RightPanel() {
icon={<GraduationCap className="w-4 h-4" />}
label="学习"
/>
<TabButton
active={activeTab === 'evolution'}
onClick={() => setActiveTab('evolution')}
icon={<Dna className="w-4 h-4" />}
label="演化"
/>
</div>
</div>
@@ -324,6 +331,8 @@ export function RightPanel() {
<AutonomyConfig />
) : activeTab === 'learning' ? (
<ActiveLearningPanel />
) : activeTab === 'evolution' ? (
<IdentityChangeProposalPanel />
) : activeTab === 'agent' ? (
<div className="space-y-4">
<motion.div

View File

@@ -146,10 +146,19 @@ export interface ImprovementSuggestion {
priority: 'high' | 'medium' | 'low';
}
// Reflection identity proposal (from reflection engine, not yet persisted)
export interface ReflectionIdentityProposal {
agent_id: string;
field: string;
current_value: string;
proposed_value: string;
reason: string;
}
export interface ReflectionResult {
patterns: PatternObservation[];
improvements: ImprovementSuggestion[];
identity_proposals: IdentityChangeProposal[];
identity_proposals: ReflectionIdentityProposal[];
new_memories: number;
timestamp: string;
}

View File

@@ -118,6 +118,7 @@ export type {
ReflectionResult,
ReflectionState,
ReflectionConfig,
ReflectionIdentityProposal,
IdentityFiles,
IdentityChangeProposal,
IdentitySnapshot,