trading-platform-frontend-v2/src/modules/assistant/components/MemoryManagerPanel.tsx
Adrian Flores Cortes 67e54d6519 [REMEDIATION] feat: Frontend remediation across auth, payments, portfolio, trading, marketplace modules
Enhance SecuritySettings page, PortfolioDetailPage, AgentsPage. Add marketplace
and payment services/types. Fix barrel exports across 8 modules.
Addresses frontend gaps from TASK-2026-02-05 analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:22 -06:00

454 lines
17 KiB
TypeScript

/**
* MemoryManagerPanel Component
* Enhanced memory management with categories, edit/delete, and API integration
* OQI-007: LLM Strategy Agent - Memory Management UI
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
CircleStackIcon,
PencilIcon,
TrashIcon,
PlusIcon,
FolderIcon,
LightBulbIcon,
ChartBarIcon,
ClockIcon,
CheckIcon,
XMarkIcon,
MagnifyingGlassIcon,
ArrowPathIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export type MemoryCategory = 'preferences' | 'strategies' | 'history' | 'custom';
export interface MemoryItem {
id: string;
category: MemoryCategory;
key: string;
value: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
isPinned?: boolean;
}
export interface MemoryStats {
totalItems: number;
totalTokens: number;
maxTokens: number;
byCategory: Record<MemoryCategory, number>;
}
interface MemoryManagerPanelProps {
memories: MemoryItem[];
stats: MemoryStats;
onAddMemory: (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
onUpdateMemory: (id: string, updates: Partial<MemoryItem>) => Promise<void>;
onDeleteMemory: (id: string) => Promise<void>;
onClearCategory?: (category: MemoryCategory) => Promise<void>;
onRefresh?: () => Promise<void>;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const CATEGORY_INFO: Record<MemoryCategory, { label: string; icon: React.ElementType; color: string }> = {
preferences: { label: 'Preferencias', icon: LightBulbIcon, color: 'text-amber-500 bg-amber-500/10' },
strategies: { label: 'Estrategias', icon: ChartBarIcon, color: 'text-blue-500 bg-blue-500/10' },
history: { label: 'Historial', icon: ClockIcon, color: 'text-purple-500 bg-purple-500/10' },
custom: { label: 'Personalizado', icon: FolderIcon, color: 'text-gray-500 bg-gray-500/10' },
};
// ============================================================================
// Sub-Components
// ============================================================================
interface MemoryItemCardProps {
memory: MemoryItem;
onEdit: () => void;
onDelete: () => void;
isEditing: boolean;
editValue: string;
onEditChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
}
const MemoryItemCard: React.FC<MemoryItemCardProps> = ({
memory,
onEdit,
onDelete,
isEditing,
editValue,
onEditChange,
onSave,
onCancel,
}) => {
const [confirmDelete, setConfirmDelete] = useState(false);
const category = CATEGORY_INFO[memory.category];
const CategoryIcon = category.icon;
return (
<div className="group p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 transition-colors">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${category.color}`}>
<CategoryIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">{memory.key}</span>
{memory.isPinned && (
<span className="px-1.5 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">
Fijado
</span>
)}
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => onEditChange(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={3}
autoFocus
/>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Cancelar
</button>
<button
onClick={onSave}
className="px-3 py-1.5 text-xs bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
Guardar
</button>
</div>
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{memory.value}</p>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(memory.updatedAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{!isEditing && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!confirmDelete ? (
<>
<button
onClick={onEdit}
className="p-1.5 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg"
title="Editar"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => setConfirmDelete(true)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<TrashIcon className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => { onDelete(); setConfirmDelete(false); }}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Confirmar
</button>
<button
onClick={() => setConfirmDelete(false)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="w-4 h-4" />
</button>
</>
)}
</div>
)}
</div>
</div>
);
};
interface AddMemoryFormProps {
onAdd: (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const AddMemoryForm: React.FC<AddMemoryFormProps> = ({ onAdd, onCancel }) => {
const [category, setCategory] = useState<MemoryCategory>('custom');
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const handleSubmit = () => {
if (key.trim() && value.trim()) {
onAdd({ category, key: key.trim(), value: value.trim() });
}
};
return (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Categoria</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value as MemoryCategory)}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg"
>
{Object.entries(CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Clave</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="ej: risk_tolerance"
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Valor</label>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Contenido de la memoria..."
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg resize-none"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700">
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!key.trim() || !value.trim()}
className="px-4 py-2 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
>
Agregar Memoria
</button>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const MemoryManagerPanel: React.FC<MemoryManagerPanelProps> = ({
memories,
stats,
onAddMemory,
onUpdateMemory,
onDeleteMemory,
onClearCategory,
onRefresh,
isLoading = false,
}) => {
const [activeCategory, setActiveCategory] = useState<MemoryCategory | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const filteredMemories = useMemo(() => {
let filtered = memories;
if (activeCategory !== 'all') {
filtered = filtered.filter((m) => m.category === activeCategory);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(m) => m.key.toLowerCase().includes(query) || m.value.toLowerCase().includes(query)
);
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [memories, activeCategory, searchQuery]);
const handleAdd = useCallback(async (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
await onAddMemory(memory);
setShowAddForm(false);
}, [onAddMemory]);
const handleEdit = useCallback((memory: MemoryItem) => {
setEditingId(memory.id);
setEditValue(memory.value);
}, []);
const handleSave = useCallback(async (id: string) => {
await onUpdateMemory(id, { value: editValue });
setEditingId(null);
setEditValue('');
}, [editValue, onUpdateMemory]);
const usagePercent = (stats.totalTokens / stats.maxTokens) * 100;
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<CircleStackIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Memoria del Agente</h3>
</div>
<div className="flex items-center gap-2">
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
<PlusIcon className="w-4 h-4" />
Agregar
</button>
</div>
</div>
{/* Usage Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500">{stats.totalItems} memorias</span>
<span className={usagePercent > 80 ? 'text-red-500' : 'text-gray-500'}>
{stats.totalTokens.toLocaleString()} / {stats.maxTokens.toLocaleString()} tokens
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${usagePercent > 80 ? 'bg-red-500' : usagePercent > 60 ? 'bg-yellow-500' : 'bg-emerald-500'}`}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
{usagePercent > 80 && (
<div className="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400">
<ExclamationTriangleIcon className="w-4 h-4" />
<span>Memoria casi llena. Considera eliminar memorias antiguas.</span>
</div>
)}
</div>
{/* Category Stats */}
<div className="grid grid-cols-4 gap-2">
{Object.entries(CATEGORY_INFO).map(([key, info]) => {
const Icon = info.icon;
const count = stats.byCategory[key as MemoryCategory] || 0;
return (
<button
key={key}
onClick={() => setActiveCategory(activeCategory === key ? 'all' : key as MemoryCategory)}
className={`p-2 rounded-lg border transition-colors ${
activeCategory === key
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<Icon className={`w-4 h-4 mx-auto mb-1 ${info.color.split(' ')[0]}`} />
<div className="text-lg font-bold text-gray-900 dark:text-white">{count}</div>
<div className="text-xs text-gray-500 truncate">{info.label}</div>
</button>
);
})}
</div>
</div>
{/* Add Form */}
{showAddForm && (
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<AddMemoryForm onAdd={handleAdd} onCancel={() => setShowAddForm(false)} />
</div>
)}
{/* Search */}
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar memorias..."
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{/* Memory List */}
<div className="max-h-96 overflow-y-auto p-3 space-y-2">
{filteredMemories.length === 0 ? (
<div className="p-8 text-center">
<CircleStackIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">
{searchQuery ? 'No se encontraron memorias' : 'Sin memorias guardadas'}
</p>
<p className="text-gray-400 text-xs mt-1">
Las memorias ayudan al agente a recordar tus preferencias
</p>
</div>
) : (
filteredMemories.map((memory) => (
<MemoryItemCard
key={memory.id}
memory={memory}
onEdit={() => handleEdit(memory)}
onDelete={() => onDeleteMemory(memory.id)}
isEditing={editingId === memory.id}
editValue={editValue}
onEditChange={setEditValue}
onSave={() => handleSave(memory.id)}
onCancel={() => { setEditingId(null); setEditValue(''); }}
/>
))
)}
</div>
{/* Clear Category Footer */}
{activeCategory !== 'all' && filteredMemories.length > 0 && onClearCategory && (
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<button
onClick={() => onClearCategory(activeCategory)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
Limpiar {CATEGORY_INFO[activeCategory].label} ({filteredMemories.length})
</button>
</div>
)}
</div>
);
};
export default MemoryManagerPanel;