Files
zclaw_openfang/desktop/src/components/IdentityChangeProposal.tsx
iven 5c74e74f2a fix(desktop): component cleanup + dead code removal + DeerFlow ai-elements
- ChatArea: DeerFlow ai-elements annotations for accessibility
- Conversation: remove unused Context, simplify message rendering
- Delete dead modules: audit-logger.ts, gateway-reconnect.ts
- Replace console.log with structured logger across components
- Add idb dependency for IndexedDB persistence
- Fix kernel-skills type safety improvements
2026-04-03 00:28:58 +08:00

501 lines
17 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 { useConversationStore } from '../store/chat/conversationStore';
import { Button, Badge } from './ui';
// === Error Parsing Utility ===
type ProposalOperation = 'approval' | 'rejection' | 'restore';
function parseProposalError(err: unknown, operation: ProposalOperation): string {
const errorMessage = err instanceof Error ? err.message : String(err);
if (errorMessage.includes('not found') || errorMessage.includes('不存在')) {
return '提案不存在或已被处理,请刷新页面';
}
if (errorMessage.includes('not pending') || errorMessage.includes('已处理')) {
return '该提案已被处理,请刷新页面';
}
if (errorMessage.includes('network') || errorMessage.includes('fetch') || errorMessage.includes('网络')) {
return '网络连接失败,请检查网络后重试';
}
if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
return '操作超时,请重试';
}
const operationName = operation === 'approval' ? '审批' : operation === 'rejection' ? '拒绝' : '恢复';
return `${operationName}失败: ${errorMessage}`;
}
// === 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 = useConversationStore((s) => s.currentAgent);
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(parseProposalError(err, 'approval'));
} 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(parseProposalError(err, 'rejection'));
} 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(parseProposalError(err, 'restore'));
} 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;