diff --git a/src/modules/assistant/components/ContextPanel.tsx b/src/modules/assistant/components/ContextPanel.tsx new file mode 100644 index 0000000..e2d4365 --- /dev/null +++ b/src/modules/assistant/components/ContextPanel.tsx @@ -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 = ({ + 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 ( +
+ {/* Header */} + + + {/* Content */} + {isExpanded && ( +
+ {/* Section Tabs */} +
+ + + +
+ + {/* Watchlist Section */} + {activeSection === 'watchlist' && ( +
+ {watchlist.length === 0 ? ( +
+

Sin simbolos en watchlist

+

+ Agrega simbolos desde Trading +

+
+ ) : ( + watchlist.slice(0, 5).map((item) => ( +
+
+ + {item.symbol} + +

{item.name}

+
+ {item.price && ( +
+ + ${item.price.toFixed(2)} + + {item.change !== undefined && ( +

= 0 ? 'text-green-500' : 'text-red-500' + }`} + > + {item.change >= 0 ? '+' : ''} + {item.change.toFixed(2)}% +

+ )} +
+ )} +
+ )) + )} + {watchlist.length > 5 && ( +

+ +{watchlist.length - 5} mas +

+ )} +
+ )} + + {/* Risk Profile Section */} + {activeSection === 'risk' && ( +
+ {riskProfiles.map((profile) => ( + + ))} +

+ El AI ajustara sus recomendaciones segun tu perfil +

+
+ )} + + {/* Preferred Symbols Section */} + {activeSection === 'prefs' && ( +
+ {preferredSymbols.length === 0 ? ( +
+

Sin simbolos favoritos

+

+ Marca simbolos como favoritos para analisis rapido +

+
+ ) : ( +
+ {preferredSymbols.map((symbol) => ( + + + {symbol} + + ))} +
+ )} +
+ )} + + {/* Quick Info */} +
+
+ Perfil actual: + + {currentRiskProfile.label} + +
+
+
+ )} +
+ ); +}; + +export default ContextPanel; diff --git a/src/modules/assistant/components/ConversationHistory.tsx b/src/modules/assistant/components/ConversationHistory.tsx new file mode 100644 index 0000000..7cdcfee --- /dev/null +++ b/src/modules/assistant/components/ConversationHistory.tsx @@ -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 = ({ + sessions, + currentSessionId, + loading, + onSelectSession, + onCreateSession, + onDeleteSession, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [deleteConfirm, setDeleteConfirm] = useState(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 ( +
+ {/* Header with New Chat button */} +
+ +
+ + {/* Search */} +
+
+ + 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" + /> +
+
+ + {/* Session List */} +
+ {loading && sessions.length === 0 ? ( +
+
+

Cargando conversaciones...

+
+ ) : filteredSessions.length === 0 ? ( +
+
+ +
+

+ {searchTerm ? 'No se encontraron resultados' : 'Sin conversaciones'} +

+

+ {searchTerm ? 'Intenta con otros terminos' : 'Inicia una nueva conversacion'} +

+
+ ) : ( +
+ {filteredSessions.map((session) => { + const isActive = session.id === currentSessionId; + const isDeleting = deleteConfirm === session.id; + + return ( +
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' + }`} + > +
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+

+ {getSessionTitle(session)} +

+

+ {getLastMessagePreview(session)} +

+

+ {formatDistanceToNow(new Date(session.updatedAt), { + addSuffix: true, + locale: es, + })} +

+
+ + {/* Delete Button */} + +
+ + {/* Message count badge */} + {session.messages.length > 0 && ( +
+ + {session.messages.length} msg + +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Footer stats */} +
+
+ {sessions.length} conversaciones + + {sessions.reduce((acc, s) => acc + s.messages.length, 0)} mensajes + +
+
+
+ ); +}; + +export default ConversationHistory; diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts new file mode 100644 index 0000000..510ef68 --- /dev/null +++ b/src/modules/assistant/components/index.ts @@ -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'; diff --git a/src/modules/assistant/pages/Assistant.tsx b/src/modules/assistant/pages/Assistant.tsx index e713f67..dac1818 100644 --- a/src/modules/assistant/pages/Assistant.tsx +++ b/src/modules/assistant/pages/Assistant.tsx @@ -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([ - { - 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(() => - `conv-${Date.now()}` - ); - const [signal, setSignal] = useState(null); - const [conversations, setConversations] = useState([]); - 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(null); + const [riskProfile, setRiskProfile] = React.useState<'conservative' | 'moderate' | 'aggressive'>('moderate'); + const [isStreaming, setIsStreaming] = React.useState(false); const messagesEndRef = useRef(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 (
{/* Sidebar - Conversation History */} - {showSidebar && ( -
- {/* Header */} -
- -
- - {/* Conversation list */} -
- {conversations.length === 0 ? ( -
- No previous conversations -
- ) : ( - conversations.map((conv) => ( - - )) - )} -
- - {/* Settings */} -
- -
-
- )} +
+ +
{/* Main Chat Area */} -
+
{/* Header */}
+
@@ -201,37 +159,72 @@ const Assistant: React.FC = () => {
+
- {/* Messages */} -
-
- {messages.map((message) => ( - + {/* Error Banner */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Messages Area */} +
+
+ {/* Context Panel (collapsible) */} + {showContext && ( +
+ +
+ )} + + {/* Messages */} + {allMessages.map((message) => ( + ))} {/* Loading indicator */} - {isLoading && ( + {loading && (
@@ -240,7 +233,7 @@ const Assistant: React.FC = () => {
- Thinking... + Pensando...
@@ -259,10 +252,11 @@ const Assistant: React.FC = () => { {/* Input */}