[OQI-007] feat: Add ConversationHistory and ContextPanel components
- ConversationHistory: Session list with search, delete, and selection - ContextPanel: Display trading context (watchlist, risk profile, favorites) - Update Assistant page to use chatStore for session management - Wire sidebar to load/create/delete sessions - Add error banner and responsive design Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ee307ee91a
commit
442051f93b
263
src/modules/assistant/components/ContextPanel.tsx
Normal file
263
src/modules/assistant/components/ContextPanel.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* ContextPanel Component
|
||||
* Displays current trading context: watchlist, risk profile, preferences
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
EyeIcon,
|
||||
ShieldCheckIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface WatchlistItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price?: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
interface ContextPanelProps {
|
||||
watchlist?: WatchlistItem[];
|
||||
riskProfile?: 'conservative' | 'moderate' | 'aggressive';
|
||||
preferredSymbols?: string[];
|
||||
onRiskProfileChange?: (profile: 'conservative' | 'moderate' | 'aggressive') => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ContextPanel: React.FC<ContextPanelProps> = ({
|
||||
watchlist = [],
|
||||
riskProfile = 'moderate',
|
||||
preferredSymbols = [],
|
||||
onRiskProfileChange,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<'watchlist' | 'risk' | 'prefs'>('watchlist');
|
||||
|
||||
const riskProfiles = [
|
||||
{
|
||||
id: 'conservative' as const,
|
||||
label: 'Conservador',
|
||||
description: 'Bajo riesgo, retornos estables',
|
||||
color: 'text-green-500',
|
||||
bg: 'bg-green-100 dark:bg-green-900/30',
|
||||
},
|
||||
{
|
||||
id: 'moderate' as const,
|
||||
label: 'Moderado',
|
||||
description: 'Balance riesgo/retorno',
|
||||
color: 'text-yellow-500',
|
||||
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
},
|
||||
{
|
||||
id: 'aggressive' as const,
|
||||
label: 'Agresivo',
|
||||
description: 'Alto riesgo, alto retorno',
|
||||
color: 'text-red-500',
|
||||
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||
},
|
||||
];
|
||||
|
||||
const currentRiskProfile = riskProfiles.find((p) => p.id === riskProfile) || riskProfiles[1];
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdjustmentsHorizontalIcon className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
Contexto de Trading
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
{/* Section Tabs */}
|
||||
<div className="flex items-center gap-1 mb-3 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveSection('watchlist')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
|
||||
activeSection === 'watchlist'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" />
|
||||
Watchlist
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('risk')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
|
||||
activeSection === 'risk'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheckIcon className="w-3.5 h-3.5" />
|
||||
Riesgo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('prefs')}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1.5 px-2 rounded-md text-xs font-medium transition-colors ${
|
||||
activeSection === 'prefs'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<StarIcon className="w-3.5 h-3.5" />
|
||||
Favoritos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Watchlist Section */}
|
||||
{activeSection === 'watchlist' && (
|
||||
<div className="space-y-2">
|
||||
{watchlist.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-xs text-gray-500">Sin simbolos en watchlist</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Agrega simbolos desde Trading
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
watchlist.slice(0, 5).map((item) => (
|
||||
<div
|
||||
key={item.symbol}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-white">
|
||||
{item.symbol}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500">{item.name}</p>
|
||||
</div>
|
||||
{item.price && (
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${item.price.toFixed(2)}
|
||||
</span>
|
||||
{item.change !== undefined && (
|
||||
<p
|
||||
className={`text-xs ${
|
||||
item.change >= 0 ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{item.change >= 0 ? '+' : ''}
|
||||
{item.change.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{watchlist.length > 5 && (
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
+{watchlist.length - 5} mas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Profile Section */}
|
||||
{activeSection === 'risk' && (
|
||||
<div className="space-y-2">
|
||||
{riskProfiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => onRiskProfileChange?.(profile.id)}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg border transition-all ${
|
||||
riskProfile === profile.id
|
||||
? `${profile.bg} border-current ${profile.color}`
|
||||
: 'border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
profile.id === 'conservative'
|
||||
? 'bg-green-500'
|
||||
: profile.id === 'moderate'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
riskProfile === profile.id
|
||||
? profile.color
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{profile.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{profile.description}</p>
|
||||
</div>
|
||||
{riskProfile === profile.id && (
|
||||
<ShieldCheckIcon className={`w-4 h-4 ml-auto ${profile.color}`} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
El AI ajustara sus recomendaciones segun tu perfil
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preferred Symbols Section */}
|
||||
{activeSection === 'prefs' && (
|
||||
<div className="space-y-2">
|
||||
{preferredSymbols.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-xs text-gray-500">Sin simbolos favoritos</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Marca simbolos como favoritos para analisis rapido
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{preferredSymbols.map((symbol) => (
|
||||
<span
|
||||
key={symbol}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
<StarIcon className="w-3 h-3" />
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Perfil actual:</span>
|
||||
<span className={`font-medium ${currentRiskProfile.color}`}>
|
||||
{currentRiskProfile.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextPanel;
|
||||
218
src/modules/assistant/components/ConversationHistory.tsx
Normal file
218
src/modules/assistant/components/ConversationHistory.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* ConversationHistory Component
|
||||
* Displays list of chat sessions with actions to load, create, and delete
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { SparklesIcon } from '@heroicons/react/24/solid';
|
||||
import type { ChatSession } from '../../../types/chat.types';
|
||||
|
||||
interface ConversationHistoryProps {
|
||||
sessions: ChatSession[];
|
||||
currentSessionId: string | null;
|
||||
loading: boolean;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onCreateSession: () => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const ConversationHistory: React.FC<ConversationHistoryProps> = ({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
loading,
|
||||
onSelectSession,
|
||||
onCreateSession,
|
||||
onDeleteSession,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// Filter sessions by search term
|
||||
const filteredSessions = sessions.filter((session) => {
|
||||
if (!searchTerm.trim()) return true;
|
||||
const lastMessage = session.messages[session.messages.length - 1];
|
||||
return (
|
||||
lastMessage?.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
session.id.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Get session title from first user message or default
|
||||
const getSessionTitle = (session: ChatSession): string => {
|
||||
const firstUserMessage = session.messages.find((m) => m.role === 'user');
|
||||
if (firstUserMessage) {
|
||||
return firstUserMessage.content.slice(0, 40) + (firstUserMessage.content.length > 40 ? '...' : '');
|
||||
}
|
||||
return 'Nueva conversacion';
|
||||
};
|
||||
|
||||
// Get last message preview
|
||||
const getLastMessagePreview = (session: ChatSession): string => {
|
||||
const lastMessage = session.messages[session.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const preview = lastMessage.content.slice(0, 50);
|
||||
return preview + (lastMessage.content.length > 50 ? '...' : '');
|
||||
}
|
||||
return 'Sin mensajes';
|
||||
};
|
||||
|
||||
const handleDelete = (sessionId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (deleteConfirm === sessionId) {
|
||||
onDeleteSession(sessionId);
|
||||
setDeleteConfirm(null);
|
||||
} else {
|
||||
setDeleteConfirm(sessionId);
|
||||
// Auto-cancel after 3 seconds
|
||||
setTimeout(() => setDeleteConfirm(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with New Chat button */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onCreateSession}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
<span className="font-medium">Nueva Conversacion</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2">
|
||||
<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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Buscar conversaciones..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-0 rounded-lg focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session List */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-3" />
|
||||
<p className="text-sm text-gray-500">Cargando conversaciones...</p>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
|
||||
<ChatBubbleLeftRightIcon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
{searchTerm ? 'No se encontraron resultados' : 'Sin conversaciones'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{searchTerm ? 'Intenta con otros terminos' : 'Inicia una nueva conversacion'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredSessions.map((session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
const isDeleting = deleteConfirm === session.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
className={`group relative p-3 rounded-lg cursor-pointer transition-all ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<SparklesIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`font-medium text-sm truncate ${
|
||||
isActive
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{getSessionTitle(session)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate mt-0.5">
|
||||
{getLastMessagePreview(session)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatDistanceToNow(new Date(session.updatedAt), {
|
||||
addSuffix: true,
|
||||
locale: es,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => handleDelete(session.id, e)}
|
||||
className={`flex-shrink-0 p-1.5 rounded-md transition-all ${
|
||||
isDeleting
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
|
||||
: 'opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
}`}
|
||||
title={isDeleting ? 'Click para confirmar' : 'Eliminar'}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message count badge */}
|
||||
{session.messages.length > 0 && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{session.messages.length} msg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer stats */}
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{sessions.length} conversaciones</span>
|
||||
<span>
|
||||
{sessions.reduce((acc, s) => acc + s.messages.length, 0)} mensajes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationHistory;
|
||||
16
src/modules/assistant/components/index.ts
Normal file
16
src/modules/assistant/components/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Assistant Components Index
|
||||
* Export all LLM copilot components
|
||||
*/
|
||||
|
||||
export { default as ChatMessage } from './ChatMessage';
|
||||
export type { Message } from './ChatMessage';
|
||||
|
||||
export { default as ChatInput } from './ChatInput';
|
||||
|
||||
export { default as SignalCard } from './SignalCard';
|
||||
export type { TradingSignal } from './SignalCard';
|
||||
|
||||
export { default as ConversationHistory } from './ConversationHistory';
|
||||
|
||||
export { default as ContextPanel } from './ContextPanel';
|
||||
@ -1,195 +1,153 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
/**
|
||||
* Assistant Page
|
||||
* Full-page LLM trading copilot with conversation history and context
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
SparklesIcon,
|
||||
Cog6ToothIcon,
|
||||
TrashIcon,
|
||||
ArrowPathIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import ChatMessage, { Message } from '../components/ChatMessage';
|
||||
import ChatMessage from '../components/ChatMessage';
|
||||
import ChatInput from '../components/ChatInput';
|
||||
import SignalCard, { TradingSignal } from '../components/SignalCard';
|
||||
import ConversationHistory from '../components/ConversationHistory';
|
||||
import ContextPanel from '../components/ContextPanel';
|
||||
import { useChatStore } from '../../../stores/chatStore';
|
||||
// TODO: Integrate with tradingStore for real watchlist data
|
||||
// import { useTradingStore } from '../../../stores/tradingStore';
|
||||
|
||||
// API base URL
|
||||
const LLM_API_URL = import.meta.env.VITE_LLM_URL || 'http://localhost:8003';
|
||||
|
||||
interface ConversationInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
// Mock watchlist data (would come from tradingStore in production)
|
||||
const mockWatchlist = [
|
||||
{ symbol: 'BTC/USD', name: 'Bitcoin', price: 43250.00, change: 2.34 },
|
||||
{ symbol: 'ETH/USD', name: 'Ethereum', price: 2280.50, change: -1.12 },
|
||||
{ symbol: 'XAUUSD', name: 'Gold', price: 2045.30, change: 0.45 },
|
||||
{ symbol: 'EURUSD', name: 'Euro/Dollar', price: 1.0875, change: -0.22 },
|
||||
];
|
||||
|
||||
const Assistant: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I\'m your OrbiQuant AI trading assistant. I can help you with:\n\n- **Market Analysis**: Get signals and analysis for any symbol\n- **Portfolio Overview**: Check your positions and P&L\n- **Trading Education**: Learn about indicators, patterns, and strategies\n- **AMD Phases**: Understand market cycles\n\nWhat would you like to know?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string>(() =>
|
||||
`conv-${Date.now()}`
|
||||
);
|
||||
const [signal, setSignal] = useState<TradingSignal | null>(null);
|
||||
const [conversations, setConversations] = useState<ConversationInfo[]>([]);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
// Chat store
|
||||
const {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
loadSessions,
|
||||
loadSession,
|
||||
createNewSession,
|
||||
sendMessage,
|
||||
deleteSession,
|
||||
clearError,
|
||||
} = useChatStore();
|
||||
|
||||
// UI State
|
||||
const [showSidebar, setShowSidebar] = React.useState(true);
|
||||
const [showContext, setShowContext] = React.useState(true);
|
||||
const [signal, setSignal] = React.useState<TradingSignal | null>(null);
|
||||
const [riskProfile, setRiskProfile] = React.useState<'conservative' | 'moderate' | 'aggressive'>('moderate');
|
||||
const [isStreaming, setIsStreaming] = React.useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Generate unique message ID
|
||||
const generateId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Send message to LLM API
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
// Handle send message
|
||||
const handleSendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${LLM_API_URL}/api/v1/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: 'user-1', // TODO: Get from auth context
|
||||
conversation_id: conversationId,
|
||||
message: content,
|
||||
user_plan: 'pro',
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: data.response || data.message || 'I apologize, but I couldn\'t process that request.',
|
||||
timestamp: new Date(),
|
||||
tools_used: data.tools_used || [],
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// Check if response contains a signal
|
||||
if (data.signal) {
|
||||
setSignal(data.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
|
||||
// Add error message
|
||||
const errorMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: 'I apologize, but I\'m having trouble connecting to my services. Please try again in a moment.\n\n*Tip: Make sure the LLM service is running on port 8003*',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [conversationId]);
|
||||
await sendMessage(content);
|
||||
}, [sendMessage]);
|
||||
|
||||
// Stop streaming (for future SSE implementation)
|
||||
const stopStreaming = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Clear conversation
|
||||
// Clear current conversation
|
||||
const clearConversation = useCallback(() => {
|
||||
const newConvId = `conv-${Date.now()}`;
|
||||
setConversationId(newConvId);
|
||||
setMessages([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Conversation cleared. How can I help you?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
createNewSession();
|
||||
setSignal(null);
|
||||
}, []);
|
||||
}, [createNewSession]);
|
||||
|
||||
// Handle trade from signal card
|
||||
const handleTrade = useCallback((sig: TradingSignal) => {
|
||||
// Navigate to trading page or open order modal
|
||||
window.location.href = `/trading?symbol=${sig.symbol}&signal=${sig.signal_id}`;
|
||||
}, []);
|
||||
|
||||
// Handle session selection
|
||||
const handleSelectSession = useCallback((sessionId: string) => {
|
||||
loadSession(sessionId);
|
||||
}, [loadSession]);
|
||||
|
||||
// Handle session deletion
|
||||
const handleDeleteSession = useCallback((sessionId: string) => {
|
||||
deleteSession(sessionId);
|
||||
}, [deleteSession]);
|
||||
|
||||
// Convert store messages to component format
|
||||
const displayMessages = messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
tools_used: msg.toolsUsed,
|
||||
}));
|
||||
|
||||
// Add welcome message if no messages
|
||||
const allMessages = displayMessages.length === 0
|
||||
? [{
|
||||
id: 'welcome',
|
||||
role: 'assistant' as const,
|
||||
content: 'Hola! Soy tu asistente de trading OrbiQuant AI. Puedo ayudarte con:\n\n- **Analisis de mercado**: Senales y analisis para cualquier simbolo\n- **Tu portfolio**: Revisa tus posiciones y P&L\n- **Educacion**: Aprende sobre indicadores, patrones y estrategias\n- **Fases AMD**: Entiende los ciclos del mercado\n\nQue te gustaria saber?',
|
||||
timestamp: new Date(),
|
||||
tools_used: [],
|
||||
}]
|
||||
: displayMessages;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex bg-gray-50 dark:bg-gray-900">
|
||||
{/* Sidebar - Conversation History */}
|
||||
{showSidebar && (
|
||||
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||
<div
|
||||
className={`${
|
||||
showSidebar ? 'w-72' : 'w-0'
|
||||
} transition-all duration-300 overflow-hidden bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-shrink-0`}
|
||||
>
|
||||
<SparklesIcon className="w-5 h-5" />
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
<ConversationHistory
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
loading={loading}
|
||||
onSelectSession={handleSelectSession}
|
||||
onCreateSession={createNewSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-8">
|
||||
No previous conversations
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
className="w-full text-left p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors mb-1"
|
||||
>
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{conv.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{conv.lastMessage}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<Cog6ToothIcon className="w-5 h-5" />
|
||||
<span className="text-sm">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors lg:hidden"
|
||||
>
|
||||
{showSidebar ? (
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<Bars3Icon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 flex items-center justify-center">
|
||||
<SparklesIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
@ -201,37 +159,72 @@ const Assistant: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
className="hidden lg:flex p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={showSidebar ? 'Ocultar historial' : 'Mostrar historial'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<Bars3Icon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowContext(!showContext)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={showContext ? 'Ocultar contexto' : 'Mostrar contexto'}
|
||||
>
|
||||
<Cog6ToothIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Clear conversation"
|
||||
title="Nueva conversacion"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Regenerate response"
|
||||
title="Regenerar respuesta"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
{/* Context Panel (collapsible) */}
|
||||
{showContext && (
|
||||
<div className="mb-4">
|
||||
<ContextPanel
|
||||
watchlist={mockWatchlist}
|
||||
riskProfile={riskProfile}
|
||||
preferredSymbols={['BTC/USD', 'XAUUSD', 'EURUSD']}
|
||||
onRiskProfileChange={setRiskProfile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
{allMessages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
{loading && (
|
||||
<div className="flex justify-start mb-4">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-sm px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -240,7 +233,7 @@ const Assistant: React.FC = () => {
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">Thinking...</span>
|
||||
<span className="text-sm text-gray-500">Pensando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -259,10 +252,11 @@ const Assistant: React.FC = () => {
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSendMessage={sendMessage}
|
||||
onSendMessage={handleSendMessage}
|
||||
onStopStreaming={stopStreaming}
|
||||
isLoading={isLoading}
|
||||
isLoading={loading}
|
||||
isStreaming={isStreaming}
|
||||
placeholder={`Pregunta sobre ${mockWatchlist[0]?.symbol || 'cualquier simbolo'}...`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user