feat: implement ZCLAW Agent Intelligence Evolution Phase 1-3
Phase 1: Persistent Memory + Identity Dynamic Evolution - agent-memory.ts: MemoryManager with localStorage persistence, keyword search, deduplication, importance scoring, pruning, markdown export - agent-identity.ts: AgentIdentityManager with per-agent SOUL/AGENTS/USER.md, change proposals with approval workflow, snapshot rollback - memory-extractor.ts: Rule-based conversation memory extraction (Phase 1), LLM extraction prompt ready for Phase 2 - MemoryPanel.tsx: Memory browsing UI with search, type filter, delete, export (integrated as 4th tab in RightPanel) Phase 2: Context Governance - context-compactor.ts: Token estimation, threshold monitoring (soft/hard), memory flush before compaction, rule-based summarization - chatStore integration: auto-compact when approaching token limits Phase 3: Proactive Intelligence + Self-Reflection - heartbeat-engine.ts: Periodic checks (pending tasks, memory health, idle greeting), quiet hours, proactivity levels (silent/light/standard/autonomous) - reflection-engine.ts: Pattern analysis from memory corpus, improvement suggestions, identity change proposals, meta-memory creation Chat Flow Integration (chatStore.ts): - Pre-send: context compaction check -> memory search -> identity system prompt injection - Post-complete: async memory extraction -> reflection conversation tracking -> auto-trigger reflection Tests: 274 passing across 12 test files - agent-memory.test.ts: 42 tests - context-compactor.test.ts: 23 tests - heartbeat-reflection.test.ts: 28 tests - chatStore.test.ts: 11 tests (no regressions) Refs: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md updated with implementation progress
This commit is contained in:
360
desktop/src/components/MemoryPanel.tsx
Normal file
360
desktop/src/components/MemoryPanel.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Brain, Search, Trash2, Download, Star, Tag, Clock,
|
||||
ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
import {
|
||||
getMemoryManager,
|
||||
type MemoryEntry,
|
||||
type MemoryType,
|
||||
type MemoryStats,
|
||||
} from '../lib/agent-memory';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
|
||||
const TYPE_LABELS: Record<MemoryType, { label: string; emoji: string; color: string }> = {
|
||||
fact: { label: '事实', emoji: '📋', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
preference: { label: '偏好', emoji: '⭐', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||
lesson: { label: '经验', emoji: '💡', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
|
||||
context: { label: '上下文', emoji: '📌', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' },
|
||||
task: { label: '任务', emoji: '📝', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
|
||||
};
|
||||
|
||||
export function MemoryPanel() {
|
||||
const { currentAgent } = useChatStore();
|
||||
const agentId = currentAgent?.id || 'zclaw-main';
|
||||
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [stats, setStats] = useState<MemoryStats | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<MemoryType | 'all'>('all');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const loadMemories = useCallback(async () => {
|
||||
const mgr = getMemoryManager();
|
||||
const typeFilter = filterType !== 'all' ? { type: filterType as MemoryType } : {};
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const results = await mgr.search(searchQuery, {
|
||||
agentId,
|
||||
limit: 50,
|
||||
...typeFilter,
|
||||
});
|
||||
setMemories(results);
|
||||
} else {
|
||||
const all = await mgr.getAll(agentId, { ...typeFilter, limit: 50 });
|
||||
setMemories(all);
|
||||
}
|
||||
|
||||
const s = await mgr.stats(agentId);
|
||||
setStats(s);
|
||||
}, [agentId, searchQuery, filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemories();
|
||||
}, [loadMemories]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await getMemoryManager().forget(id);
|
||||
loadMemories();
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const md = await getMemoryManager().exportToMarkdown(agentId);
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `zclaw-memory-${agentId}-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrune = async () => {
|
||||
const pruned = await getMemoryManager().prune({
|
||||
agentId,
|
||||
maxAgeDays: 30,
|
||||
minImportance: 3,
|
||||
});
|
||||
if (pruned > 0) {
|
||||
loadMemories();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
transition={defaultTransition}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
|
||||
<Brain className="w-4 h-4 text-orange-500" />
|
||||
Agent 记忆
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title="导出记忆"
|
||||
aria-label="Export memories"
|
||||
className="p-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrune}
|
||||
title="清理旧记忆"
|
||||
aria-label="Prune old memories"
|
||||
className="p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-orange-600">{stats.totalEntries}</div>
|
||||
<div className="text-[10px] text-gray-500">总记忆</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-blue-600">{stats.byType['fact'] || 0}</div>
|
||||
<div className="text-[10px] text-gray-500">事实</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-1.5">
|
||||
<div className="text-lg font-bold text-amber-600">{stats.byType['preference'] || 0}</div>
|
||||
<div className="text-[10px] text-gray-500">偏好</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索记忆..."
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-orange-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<FilterChip
|
||||
label="全部"
|
||||
active={filterType === 'all'}
|
||||
onClick={() => setFilterType('all')}
|
||||
/>
|
||||
{(Object.keys(TYPE_LABELS) as MemoryType[]).map((type) => (
|
||||
<FilterChip
|
||||
key={type}
|
||||
label={`${TYPE_LABELS[type].emoji} ${TYPE_LABELS[type].label}`}
|
||||
active={filterType === type}
|
||||
onClick={() => setFilterType(type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory List */}
|
||||
<div className="space-y-2">
|
||||
{memories.length > 0 ? (
|
||||
<AnimatePresence initial={false}>
|
||||
{memories.map((entry) => (
|
||||
<MemoryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggle={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
onDelete={() => handleDelete(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Brain className="w-8 h-8" />}
|
||||
title={searchQuery ? '未找到匹配的记忆' : '暂无记忆'}
|
||||
description={searchQuery ? '尝试不同的搜索词' : '与 Agent 交流后,记忆会自动积累'}
|
||||
className="py-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryCard({
|
||||
entry,
|
||||
expanded,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
entry: MemoryEntry;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const typeInfo = TYPE_LABELS[entry.type];
|
||||
const importanceStars = Math.round(entry.importance / 2);
|
||||
const timeAgo = getTimeAgo(entry.createdAt);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeInfo.color}`}>
|
||||
{typeInfo.emoji} {typeInfo.label}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 line-clamp-2">{entry.content}</p>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3" />
|
||||
{'★'.repeat(importanceStars)}{'☆'.repeat(5 - importanceStars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo}
|
||||
</span>
|
||||
{entry.tags.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Tag className="w-3 h-3" />
|
||||
{entry.tags.slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<div className="px-3 py-2 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-gray-500">重要性</span>
|
||||
<span className="ml-1 font-medium">{entry.importance}/10</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">来源</span>
|
||||
<span className="ml-1 font-medium">{entry.source === 'auto' ? '自动' : entry.source === 'user' ? '用户' : '反思'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">访问</span>
|
||||
<span className="ml-1 font-medium">{entry.accessCount}次</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">创建</span>
|
||||
<span className="ml-1 font-medium">{new Date(entry.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => (
|
||||
<Badge key={tag} variant="default">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 text-xs px-2 py-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-orange-100 border-orange-300 text-orange-700 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-300'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeAgo(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}个月前`;
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { useGatewayStore, type PluginStatus } from '../store/gatewayStore';
|
||||
import { toChatAgent, useChatStore } from '../store/chatStore';
|
||||
import {
|
||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode
|
||||
MessageSquare, Cpu, FileText, User, Activity, FileCode, Brain
|
||||
} from 'lucide-react';
|
||||
import { MemoryPanel } from './MemoryPanel';
|
||||
import { cardHover, defaultTransition } from '../lib/animations';
|
||||
import { Button, Badge, EmptyState } from './ui';
|
||||
|
||||
@@ -16,7 +17,7 @@ export function RightPanel() {
|
||||
connect, loadClones, loadUsageStats, loadPluginStatus, workspaceInfo, quickConfig, updateClone,
|
||||
} = useGatewayStore();
|
||||
const { messages, currentModel, currentAgent, setCurrentAgent } = useChatStore();
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent'>('status');
|
||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory'>('status');
|
||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
||||
|
||||
@@ -139,11 +140,25 @@ export function RightPanel() {
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('memory')}
|
||||
className="flex items-center gap-1 text-xs px-2 py-1"
|
||||
title="Memory"
|
||||
aria-label="Memory"
|
||||
aria-selected={activeTab === 'memory'}
|
||||
role="tab"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
|
||||
{activeTab === 'agent' ? (
|
||||
{activeTab === 'memory' ? (
|
||||
<MemoryPanel />
|
||||
) : activeTab === 'agent' ? (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
whileHover={cardHover}
|
||||
|
||||
Reference in New Issue
Block a user