[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 {
|
import {
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import ChatMessage, { Message } from '../components/ChatMessage';
|
import ChatMessage from '../components/ChatMessage';
|
||||||
import ChatInput from '../components/ChatInput';
|
import ChatInput from '../components/ChatInput';
|
||||||
import SignalCard, { TradingSignal } from '../components/SignalCard';
|
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
|
// Mock watchlist data (would come from tradingStore in production)
|
||||||
const LLM_API_URL = import.meta.env.VITE_LLM_URL || 'http://localhost:8003';
|
const mockWatchlist = [
|
||||||
|
{ symbol: 'BTC/USD', name: 'Bitcoin', price: 43250.00, change: 2.34 },
|
||||||
interface ConversationInfo {
|
{ symbol: 'ETH/USD', name: 'Ethereum', price: 2280.50, change: -1.12 },
|
||||||
id: string;
|
{ symbol: 'XAUUSD', name: 'Gold', price: 2045.30, change: 0.45 },
|
||||||
title: string;
|
{ symbol: 'EURUSD', name: 'Euro/Dollar', price: 1.0875, change: -0.22 },
|
||||||
lastMessage: string;
|
];
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Assistant: React.FC = () => {
|
const Assistant: React.FC = () => {
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
// Chat store
|
||||||
{
|
const {
|
||||||
id: '1',
|
sessions,
|
||||||
role: 'assistant',
|
currentSessionId,
|
||||||
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?',
|
messages,
|
||||||
timestamp: new Date(),
|
loading,
|
||||||
},
|
error,
|
||||||
]);
|
loadSessions,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
loadSession,
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
createNewSession,
|
||||||
const [conversationId, setConversationId] = useState<string>(() =>
|
sendMessage,
|
||||||
`conv-${Date.now()}`
|
deleteSession,
|
||||||
);
|
clearError,
|
||||||
const [signal, setSignal] = useState<TradingSignal | null>(null);
|
} = useChatStore();
|
||||||
const [conversations, setConversations] = useState<ConversationInfo[]>([]);
|
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
// 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);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load sessions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
// Scroll to bottom on new messages
|
// Scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Generate unique message ID
|
// Handle send message
|
||||||
const generateId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const handleSendMessage = useCallback(async (content: string) => {
|
||||||
|
|
||||||
// Send message to LLM API
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) return;
|
||||||
|
await sendMessage(content);
|
||||||
// Add user message
|
}, [sendMessage]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Stop streaming (for future SSE implementation)
|
// Stop streaming (for future SSE implementation)
|
||||||
const stopStreaming = useCallback(() => {
|
const stopStreaming = useCallback(() => {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Clear conversation
|
// Clear current conversation
|
||||||
const clearConversation = useCallback(() => {
|
const clearConversation = useCallback(() => {
|
||||||
const newConvId = `conv-${Date.now()}`;
|
createNewSession();
|
||||||
setConversationId(newConvId);
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
role: 'assistant',
|
|
||||||
content: 'Conversation cleared. How can I help you?',
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setSignal(null);
|
setSignal(null);
|
||||||
}, []);
|
}, [createNewSession]);
|
||||||
|
|
||||||
// Handle trade from signal card
|
// Handle trade from signal card
|
||||||
const handleTrade = useCallback((sig: TradingSignal) => {
|
const handleTrade = useCallback((sig: TradingSignal) => {
|
||||||
// Navigate to trading page or open order modal
|
|
||||||
window.location.href = `/trading?symbol=${sig.symbol}&signal=${sig.signal_id}`;
|
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 (
|
return (
|
||||||
<div className="h-[calc(100vh-64px)] flex bg-gray-50 dark:bg-gray-900">
|
<div className="h-[calc(100vh-64px)] flex bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Sidebar - Conversation History */}
|
{/* Sidebar - Conversation History */}
|
||||||
{showSidebar && (
|
<div
|
||||||
<div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
className={`${
|
||||||
{/* Header */}
|
showSidebar ? 'w-72' : 'w-0'
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
} transition-all duration-300 overflow-hidden bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-shrink-0`}
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<SparklesIcon className="w-5 h-5" />
|
<ConversationHistory
|
||||||
<span>New Chat</span>
|
sessions={sessions}
|
||||||
</button>
|
currentSessionId={currentSessionId}
|
||||||
|
loading={loading}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onCreateSession={createNewSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Main Chat Area */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Header */}
|
{/* 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 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">
|
<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">
|
<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" />
|
<SparklesIcon className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
@ -201,37 +159,72 @@ const Assistant: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSidebar(!showSidebar)}
|
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">
|
<Bars3Icon className="w-5 h-5" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
</button>
|
||||||
</svg>
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={clearConversation}
|
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"
|
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" />
|
<TrashIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<ArrowPathIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
{allMessages.map((message) => (
|
||||||
<div className="max-w-3xl mx-auto">
|
<ChatMessage
|
||||||
{messages.map((message) => (
|
key={message.id}
|
||||||
<ChatMessage key={message.id} message={message} />
|
message={message}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
{isLoading && (
|
{loading && (
|
||||||
<div className="flex justify-start mb-4">
|
<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="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-sm px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<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: '150ms' }} />
|
||||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">Thinking...</span>
|
<span className="text-sm text-gray-500">Pensando...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -259,10 +252,11 @@ const Assistant: React.FC = () => {
|
|||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSendMessage={sendMessage}
|
onSendMessage={handleSendMessage}
|
||||||
onStopStreaming={stopStreaming}
|
onStopStreaming={stopStreaming}
|
||||||
isLoading={isLoading}
|
isLoading={loading}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
placeholder={`Pregunta sobre ${mockWatchlist[0]?.symbol || 'cualquier simbolo'}...`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user