From bfad61c3da49459ddb0f2e761e87a145d222f94c Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 24 Mar 2026 00:51:03 +0800 Subject: [PATCH] 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 --- .../src/components/IdentityChangeProposal.tsx | 476 ++++++++++++++++++ desktop/src/components/ReflectionLog.tsx | 31 +- desktop/src/components/RightPanel.tsx | 13 +- desktop/src/lib/intelligence-backend.ts | 11 +- desktop/src/lib/intelligence-client.ts | 1 + 5 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 desktop/src/components/IdentityChangeProposal.tsx diff --git a/desktop/src/components/IdentityChangeProposal.tsx b/desktop/src/components/IdentityChangeProposal.tsx new file mode 100644 index 0000000..cc41e41 --- /dev/null +++ b/desktop/src/components/IdentityChangeProposal.tsx @@ -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 ( +
+ {/* Current */} +
+
+ 当前版本 +
+
+ {diffLines.map((line, idx) => ( +
+ {line.type === 'removed' && -} + {line.type === 'modified' && ~} + {line.current || '\u00A0'} +
+ ))} +
+
+ + {/* Proposed */} +
+
+ 建议版本 +
+
+ {diffLines.map((line, idx) => ( +
+ {line.type === 'added' && +} + {line.type === 'modified' && ~} + {line.proposed || '\u00A0'} +
+ ))} +
+
+
+ ); +} + +// === 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 ( + + {/* Header */} +
setExpanded(!expanded)} + > +
+
+ +
+
+
+ + 人格变更提案 + + + {fileLabel} + +
+
+ + {timeAgo} +
+
+
+
+ {expanded ? ( + + ) : ( + + )} +
+
+ + {/* Content */} + + {expanded && ( + +
+ {/* Reason */} +
+
+ 变更原因 +
+

{proposal.reason}

+
+ + {/* Diff View */} + + + {/* Actions */} +
+ + +
+
+
+ )} +
+
+ ); +} + +// === Evolution History Item === + +function HistoryItem({ + snapshot, + onRestore, + isRestoring, +}: { + snapshot: IdentitySnapshot; + onRestore: () => void; + isRestoring: boolean; +}) { + const timeAgo = getTimeAgo(snapshot.timestamp); + + return ( +
+
+ +
+
+
+ {timeAgo} + +
+

+ {snapshot.reason || '自动快照'} +

+
+
+ ); +} + +// === Main Component === + +export function IdentityChangeProposalPanel() { + const { currentAgent } = useChatStore(); + const [proposals, setProposals] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [processingId, setProcessingId] = useState(null); + const [error, setError] = useState(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 ( +
+ +

请先选择一个 Agent

+
+ ); + } + + if (loading) { + return ( +
+
加载中...
+
+ ); + } + + return ( +
+ {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Pending Proposals */} +
+

+ + 待审批提案 + {proposals.length > 0 && ( + + {proposals.length} + + )} +

+ + {proposals.length === 0 ? ( +
+ 暂无待审批的人格变更提案 +
+ ) : ( +
+ + {proposals.map((proposal) => ( + handleApprove(proposal.id)} + onReject={() => handleReject(proposal.id)} + isProcessing={processingId === proposal.id} + /> + ))} + +
+ )} +
+ + {/* Evolution History */} +
+

+ + 演化历史 +

+ + {snapshots.length === 0 ? ( +
+ 暂无演化历史 +
+ ) : ( +
+ {snapshots.map((snapshot) => ( + handleRestore(snapshot.id)} + isRestoring={processingId === snapshot.id} + /> + ))} +
+ )} +
+
+ ); +} + +// === 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; diff --git a/desktop/src/components/ReflectionLog.tsx b/desktop/src/components/ReflectionLog.tsx index f6a5f36..55df0dc 100644 --- a/desktop/src/components/ReflectionLog.tsx +++ b/desktop/src/components/ReflectionLog.tsx @@ -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); diff --git a/desktop/src/components/RightPanel.tsx b/desktop/src/components/RightPanel.tsx index 56a5dc6..482c6a3 100644 --- a/desktop/src/components/RightPanel.tsx +++ b/desktop/src/components/RightPanel.tsx @@ -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(null); @@ -263,6 +264,12 @@ export function RightPanel() { icon={} label="学习" /> + setActiveTab('evolution')} + icon={} + label="演化" + /> @@ -324,6 +331,8 @@ export function RightPanel() { ) : activeTab === 'learning' ? ( + ) : activeTab === 'evolution' ? ( + ) : activeTab === 'agent' ? (