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>
454 lines
17 KiB
TypeScript
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;
|