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>
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
/**
|
|
* 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;
|