import { useState, useRef, useEffect, useMemo } from 'react'; import type { ChangeEvent } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { tradingRuntime } from '../lib/runtime'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; import type { BotState } from '../hooks/useWebSocket'; import { Send, X, Bot, User, Check, Loader2, Zap, Copy } from 'lucide-react'; import { Button, Input, Select, Textarea } from './ui/Primitives'; import { cn } from '../lib/utils'; import { buildCreateExitPlanUrl, buildPlanDrillInUrl, buildPlansHomeUrl, buildSettingsSectionUrl } from '../views/tradePlansRoutes'; interface ChatMessage { id: number; role: 'user' | 'assistant'; content: string; profileData?: any; action?: ChatAssistantAction; insights?: string[]; nextActions?: string[]; quickLinks?: ChatQuickLink[]; timestamp: Date; } interface ChatControlProps { profiles: any[]; botState: BotState; onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>; } type ChatAssistantAction = | 'create_profile' | 'update_profile' | 'recommend_profile_change' | 'recommend_trade_plan' | 'recommend_reconciliation_followup' | 'review_recent_trades' | 'explain' | 'explain_position' | 'explain_waiting' | 'explain_blocker' | 'summarize_reconciliation'; type ChatQuickLink = | { kind: 'portfolio'; label: string; tradeId?: string; symbol?: string } | { kind: 'plans'; label: string; symbol?: string; tradeId?: string; setupId?: string; mode?: 'sell' | 'view' } | { kind: 'settings'; label: string; section?: 'Account' | 'Bot Config' | 'Admin Panel' }; export interface QuickAction { label: string; prompt: string; } export const BASE_QUICK_ACTIONS: QuickAction[] = [ { label: 'Conservative Trader', prompt: 'Create a conservative BTC/ETH swing trader with $2000 capital and 1% risk' }, { label: 'Aggressive Scalper', prompt: 'Build an aggressive scalper for SOL/DOGE with $500 capital, 3% risk and all rules enabled' }, { label: 'Low Risk Profile', prompt: 'Create a low-risk profile that only trades BTC during London and NY sessions with $5000 capital' }, { label: 'AI Momentum', prompt: 'Create a momentum strategy with AI analysis enabled, focusing on ETH/SOL with 2% risk' }, { label: 'Explain holding', prompt: 'Explain my current open holding and what the bot is waiting for next.' }, { label: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' }, { label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' }, { label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' }, { label: 'Fix reconciliation', prompt: 'What should I do about reconciliation right now? Recommend the safest follow-up.' }, { label: 'Review recent trades', prompt: 'Review my recent trades and tell me what to focus on next.' }, { label: 'Manage live holding', prompt: 'Recommend the safest Trade Plan action for my current live holding.' }, { label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' }, { label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' }, ]; export const cloneProfileDraft = (profileData: any) => ({ ...profileData, strategy_config: profileData?.strategy_config ? JSON.parse(JSON.stringify(profileData.strategy_config)) : undefined, }); export const normalizeName = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); export const buildQuickActions = (profiles: any[]): QuickAction[] => { const profileActions: QuickAction[] = []; const highScalper = profiles.find((profileOption) => { const normalized = normalizeName(String(profileOption?.name || '')); return normalized.includes('highriskscalper') || normalized.includes('highscal'); }); const conservativeBag = profiles.find((profileOption) => { const normalized = normalizeName(String(profileOption?.name || '')); return normalized.includes('conservativebag'); }); if (highScalper?.name) { profileActions.push({ label: `Tune ${highScalper.name}`, prompt: `Review my current profile "${highScalper.name}" and recommend improved parameters before applying an update.`, }); } if (conservativeBag?.name) { profileActions.push({ label: `Tune ${conservativeBag.name}`, prompt: `Review my current profile "${conservativeBag.name}" and recommend improved parameters before applying an update.`, }); } return [...profileActions, ...BASE_QUICK_ACTIONS]; }; export const normalizeProfileForApply = (profileData: any) => ({ ...profileData, name: String(profileData?.name || 'AI Profile').trim(), allocated_capital: Number(profileData?.allocated_capital || 0), risk_per_trade_percent: Number(profileData?.risk_per_trade_percent || 0), symbols: String(profileData?.symbols || '').trim(), is_active: profileData?.is_active !== false, }); const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' => action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change'; const formatChatError = (error: unknown) => { const message = String((error as { message?: string })?.message || '').trim(); const lower = message.toLowerCase(); if (!message) { return 'The assistant could not complete that request. Please try again.'; } if (lower.includes('not authenticated') || lower.includes('unauthorized') || lower.includes('forbidden')) { return 'Your session is not authorized for chat right now. Sign in again, then retry.'; } if (lower.includes('failed to fetch') || lower.includes('network') || lower.includes('load failed')) { return 'The chat service is temporarily unreachable. Check connectivity and retry.'; } if (lower.includes('timeout')) { return 'The chat request timed out before the assistant finished. Retry once the backend is responsive.'; } return `The assistant could not complete that request: ${message}`; }; const summarizeRuntimeContext = (botState: BotState) => ({ signalContexts: Object.entries(botState.symbols ?? {}) .flatMap(([symbol, symbolState]) => Object.entries(symbolState?.profileSignals || {}).map(([profileId, profileSignal]) => ({ symbol, profileId, profileName: profileSignal?.profileName, signal: profileSignal?.signal, passed: profileSignal?.passed, reason: profileSignal?.reason, executionStatus: profileSignal?.execution?.status, executionCode: profileSignal?.execution?.code, executionReason: profileSignal?.execution?.reason, orderId: profileSignal?.execution?.orderId, })) ) .slice(0, 20), positions: (botState.positions ?? []).slice(0, 10).map((position) => ({ symbol: position.symbol, side: position.side, size: position.size, entryPrice: position.entryPrice, currentPrice: position.currentPrice, unrealizedPnl: position.unrealizedPnl, unrealizedPnlPercent: position.unrealizedPnlPercent, profileId: position.profileId, profileName: position.profileName, tradeId: position.tradeId, stopLoss: position.stopLoss, takeProfit: position.takeProfit, })), recentOrders: (botState.orders ?? []) .slice() .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) .slice(0, 12) .map((order) => ({ id: order.id, symbol: order.symbol, side: order.side, qty: order.qty, price: order.price, status: order.status, timestamp: order.timestamp, profileId: order.profileId, tradeId: order.trade_id, action: order.action, source: order.source, })), recentHistory: (botState.history ?? []) .slice() .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) .slice(0, 12) .map((trade) => ({ symbol: trade.symbol, side: trade.side, entryPrice: trade.entryPrice, exitPrice: trade.exitPrice, pnl: trade.pnl, pnlPercent: trade.pnlPercent, reason: trade.reason, timestamp: trade.timestamp, profileId: trade.profileId, tradeId: trade.trade_id, source: trade.source, })), orderFailures: (botState.orderFailures ?? []) .slice() .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) .slice(0, 8) .map((failure) => ({ symbol: failure.symbol, side: failure.side, qty: failure.qty, reason: failure.reason, profileId: failure.profileId, tradeId: failure.tradeId, timestamp: failure.timestamp, })), operationalEvents: (botState.operationalEvents ?? []) .filter(Boolean) .slice() .sort((a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0)) .slice(0, 12) .map((event) => ({ id: event?.id, type: event?.type, severity: event?.severity, message: event?.message, symbol: event?.symbol, profileId: event?.profileId, tradeId: event?.tradeId, orderId: event?.orderId, timestamp: event?.timestamp, })), accountSnapshot: botState.accountSnapshot ? { buying_power: botState.accountSnapshot.buying_power, cash: botState.accountSnapshot.cash, currency: botState.accountSnapshot.currency, timestamp: botState.accountSnapshot.timestamp, } : null, health: botState.health ? { tradingLoopHealthy: botState.health.tradingLoopHealthy, orderSyncHealthy: botState.health.orderSyncHealthy, reconciliationLoopHealthy: botState.health.reconciliationLoopHealthy, reconciliationMismatchCount: botState.health.reconciliationMismatchCount, reconciliationMissingFromExchange: botState.health.reconciliationMissingFromExchange, reconciliationMissingInDb: botState.health.reconciliationMissingInDb, reconciliationNoGoTrades: botState.health.reconciliationNoGoTrades, reconciliationParityMismatchTrades: botState.health.reconciliationParityMismatchTrades ?? 0, reconciliationParityQuarantinedTrades: botState.health.reconciliationParityQuarantinedTrades ?? 0, reconciliationParityAutoClosedTrades: botState.health.reconciliationParityAutoClosedTrades ?? 0, reconciliationIntegrityWatchdogTriggered: botState.health.reconciliationIntegrityWatchdogTriggered, lockContentionCount: botState.health.lockContentionCount, reconciliationLockContentionCount: botState.health.reconciliationLockContentionCount, } : null, settings: botState.settings ? { executionMode: botState.settings.executionMode, totalCapital: botState.settings.totalCapital, riskPerTrade: botState.settings.riskPerTrade, maxOpenTrades: botState.settings.maxOpenTrades, isAlgoEnabled: botState.settings.isAlgoEnabled, } : null, }); // 3D Robot SVG Icon const RobotIcon = ({ size = 32 }: { size?: number }) => ( {/* Antenna */} {/* Head */} {/* Eyes */} {/* Mouth */} {/* Body */} {/* Body detail */} {/* Arms */} ); export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => { const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [messages, setMessages] = useState([ { id: 0, role: 'assistant', content: "Hi! I'm your trading assistant. Tell me what kind of strategy profile you'd like to create or modify, and I'll generate the configuration for you.\n\nTry: \"Create a conservative BTC scalper with $1000 capital\"", timestamp: new Date(), } ]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [appliedIds, setAppliedIds] = useState>(new Set()); const [cancelledIds, setCancelledIds] = useState>(new Set()); const [editingIds, setEditingIds] = useState>(new Set()); const [draftProfiles, setDraftProfiles] = useState>({}); const messagesEndRef = useRef(null); const inputRef = useRef(null); const quickActions = useMemo(() => buildQuickActions(profiles), [profiles]); const openQuickLink = (link: ChatQuickLink) => { if (link.kind === 'portfolio') { navigate('/portfolio'); setIsOpen(false); return; } if (link.kind === 'plans') { if (link.setupId) { navigate(buildPlanDrillInUrl(link.setupId)); } else if (link.mode === 'sell' && link.symbol) { navigate(buildCreateExitPlanUrl(link.symbol, link.tradeId)); } else { navigate(buildPlansHomeUrl()); } setIsOpen(false); return; } if (link.kind === 'settings') { navigate(buildSettingsSectionUrl(link.section || 'Account')); setIsOpen(false); } }; const openDraftEditor = (msg: ChatMessage) => { if (!msg.profileData) return; setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) })); setEditingIds((prev) => new Set(prev).add(msg.id)); }; const closeDraftEditor = (msgId: number) => { setEditingIds((prev) => { const next = new Set(prev); next.delete(msgId); return next; }); }; const resetDraft = (msg: ChatMessage) => { if (!msg.profileData) return; setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) })); }; const updateDraftField = (msgId: number, field: string, value: any) => { setDraftProfiles((prev) => ({ ...prev, [msgId]: { ...(prev[msgId] || {}), [field]: value, }, })); }; useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 300); } }, [isOpen]); const sendMessage = async (text?: string) => { const msg = text || input.trim(); if (!msg || isLoading) return; const userMsg: ChatMessage = { id: Date.now(), role: 'user', content: msg, timestamp: new Date(), }; setMessages(prev => [...prev, userMsg]); setInput(''); setIsLoading(true); try { const apiUrl = tradingRuntime.tradingApiUrl; const accessToken = await getPlatformAccessToken(); const res = await fetch(`${apiUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'x-request-id': createRequestId('web-chat') }, body: JSON.stringify({ message: msg, context: { profiles: profiles.map(p => ({ id: p.id, name: p.name, allocated_capital: p.allocated_capital, risk_per_trade_percent: p.risk_per_trade_percent, symbols: p.symbols, is_active: p.is_active, strategy_config: p.strategy_config, })), runtime: summarizeRuntimeContext(botState), }, }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Chat request failed'); } const data = await res.json(); const assistantMsg: ChatMessage = { id: Date.now() + 1, role: 'assistant', content: data.summary || data.reasoning || 'Profile configuration generated.', profileData: data.profile || null, action: data.action as ChatAssistantAction | undefined, insights: Array.isArray(data.insights) ? data.insights.map((entry: unknown) => String(entry)) : undefined, nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined, quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined, timestamp: new Date(), }; if (data.reasoning && data.summary) { assistantMsg.content = `${data.summary}\n\n${data.reasoning}`; } setMessages(prev => [...prev, assistantMsg]); } catch (err: any) { setMessages(prev => [...prev, { id: Date.now() + 1, role: 'assistant', content: formatChatError(err), timestamp: new Date(), }]); } setIsLoading(false); }; const handleApply = async (msg: ChatMessage) => { if (msg.profileData && isProfileMutationAction(msg.action)) { const activeDraft = draftProfiles[msg.id] || msg.profileData; const payload = normalizeProfileForApply(activeDraft); const applyAction = msg.action === 'recommend_profile_change' ? (payload.id ? 'update_profile' : 'create_profile') : msg.action; const result = await onApplyProfile(applyAction, payload); if (result.success) { setAppliedIds(prev => new Set(prev).add(msg.id)); closeDraftEditor(msg.id); setMessages(prev => [...prev, { id: Date.now(), role: 'assistant', content: applyAction === 'create_profile' ? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).` : `Profile "${payload.name}" has been updated successfully!`, timestamp: new Date(), }]); } else { setMessages(prev => [...prev, { id: Date.now(), role: 'assistant', content: `Failed to ${applyAction === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`, timestamp: new Date(), }]); } } }; const handleCancel = (msgId: number) => { setCancelledIds(prev => new Set(prev).add(msgId)); closeDraftEditor(msgId); setMessages(prev => [...prev, { id: Date.now(), role: 'assistant', content: 'Profile creation cancelled. You can ask me to create a different one or modify the parameters.', timestamp: new Date(), }]); }; const copyJson = (data: any) => { navigator.clipboard.writeText(JSON.stringify(data, null, 2)); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; const assistantTint = 'var(--accent-soft)'; const panelStyle: React.CSSProperties = { background: 'var(--card)', border: '1px solid var(--border)', boxShadow: 'var(--card-shadow)', }; const inputStyle: React.CSSProperties = { background: 'var(--input)', border: '1px solid var(--border)', color: 'var(--foreground)', boxShadow: 'none', caretColor: 'var(--ring)', }; // Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues) if (!isOpen) { return createPortal( setIsOpen(true)} variant="ghost" style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 99999, cursor: 'pointer', background: 'none', border: 'none', padding: 0, animation: 'robotFloat 3s ease-in-out infinite', }} className="group" > {/* Glow ring */} {/* Robot container */} {/* Pulse dot */} , document.body ); } return createPortal( <> {/* Backdrop */} setIsOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 999998, background: 'color-mix(in oklab, var(--background) 45%, transparent)', backdropFilter: 'blur(6px)', animation: 'fadeIn 0.15s ease-out', }} /> {/* Header */} AI Trading Copilot Create profiles, explain holdings, and diagnose blockers setIsOpen(false)} variant="ghost" size="sm" className="h-8 w-8 rounded-lg" > {/* Messages */} {messages.map(msg => ( {/* Avatar */} {msg.role === 'user' ? : } {/* Bubble */} {msg.content} {msg.nextActions && msg.nextActions.length > 0 ? ( Suggested next actions {msg.nextActions.map((nextAction, index) => ( {index + 1}. {nextAction} ))} ) : null} {msg.insights && msg.insights.length > 0 ? ( Key facts {msg.insights.map((insight, index) => ( {insight} ))} ) : null} {msg.quickLinks && msg.quickLinks.length > 0 ? ( {msg.quickLinks.map((link, index) => ( openQuickLink(link)} variant="outline" size="sm" className="h-8 rounded-full px-3 text-[11px]" > {link.label} ))} ) : null} {/* Profile preview card */} {msg.profileData && isProfileMutationAction(msg.action) && (() => { const activeProfileData = draftProfiles[msg.id] || msg.profileData; const isEditing = editingIds.has(msg.id); const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules) ? activeProfileData.strategy_config.rules.filter((r: any) => r?.enabled).length : 0; return ( {msg.action === 'create_profile' ? 'New Profile' : msg.action === 'recommend_profile_change' ? 'Suggested Profile Change' : 'Update Profile'} copyJson(activeProfileData)} variant="ghost" size="sm" className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" title="Copy JSON" > Name {activeProfileData?.name} {activeProfileData?.allocated_capital ? ( Capital ${activeProfileData.allocated_capital} ) : null} {activeProfileData?.risk_per_trade_percent ? ( Risk / Trade {activeProfileData.risk_per_trade_percent}% ) : null} {activeProfileData?.symbols ? ( Symbols {activeProfileData.symbols} ) : null} {activeRules > 0 ? ( Rules {activeRules} active ) : null} {isEditing ? ( Edit Parameters Before Apply ) => updateDraftField(msg.id, 'name', e.target.value)} placeholder="Profile Name" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none" style={inputStyle} /> ) => updateDraftField(msg.id, 'allocated_capital', e.target.value)} placeholder="Capital" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none" style={inputStyle} /> ) => updateDraftField(msg.id, 'risk_per_trade_percent', e.target.value)} placeholder="Risk %" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none" style={inputStyle} /> ) => updateDraftField(msg.id, 'symbols', e.target.value)} placeholder="Symbols (e.g. BTC/USDT,ETH/USDT)" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none" style={inputStyle} /> Auto Trading ) => updateDraftField(msg.id, 'is_active', e.target.value === 'true')} className="rounded px-2 py-1 text-[10px] outline-none" style={inputStyle} options={[ { value: 'true', label: 'Active' }, { value: 'false', label: 'Paused' }, ]} /> resetDraft(msg)} variant="outline" size="sm" className="h-8 px-2.5 text-[9px] uppercase tracking-wider" > Reset ) : null} {!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? ( handleCancel(msg.id)} variant="ghost" className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]" style={{ color: 'var(--destructive)', borderRight: '1px solid var(--border)', }} > Cancel isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)} variant="ghost" className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]" style={{ color: 'var(--bl-warning)', borderRight: '1px solid var(--border)', }} > {isEditing ? 'Done Editing' : 'Edit Params'} handleApply(msg)} variant="ghost" className="flex-[2] py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:brightness-110" style={{ background: 'var(--accent-soft)', color: 'var(--primary)', }} > Apply to Dashboard ) : cancelledIds.has(msg.id) ? ( Cancelled ) : ( Applied )} ); })()} {msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} ))} {/* Suggested quick actions - shown when only welcome message exists */} {messages.length <= 1 && !isLoading && ( Quick Actions {quickActions.map((action, i) => ( sendMessage(action.prompt)} variant="ghost" className="text-left px-3.5 py-3 rounded-xl transition-all" style={{ background: 'var(--card)', border: '1px solid var(--border)', cursor: 'pointer', }} onMouseEnter={(e: React.MouseEvent) => { e.currentTarget.style.borderColor = 'var(--ring)'; e.currentTarget.style.background = 'var(--accent-soft)'; }} onMouseLeave={(e: React.MouseEvent) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.background = 'var(--card)'; }} > {action.label} {action.prompt.slice(0, 55)}... ))} )} {isLoading && ( Thinking through your trading context... )} {/* Input area */} ) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask for a profile, holding explanation, or reconciliation help..." disabled={isLoading} rows={2} className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none" style={{ ...inputStyle, lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px' }} /> sendMessage()} disabled={!input.trim() || isLoading} variant="ghost" className="absolute right-2.5 bottom-2.5 w-8 h-8 rounded-lg flex items-center justify-center transition-all disabled:opacity-20 hover:scale-105" style={{ background: input.trim() ? 'var(--primary)' : 'var(--accent-soft)', color: input.trim() ? 'var(--primary-foreground)' : 'var(--muted-foreground)', boxShadow: input.trim() ? '0 2px 10px color-mix(in oklab, var(--primary) 25%, transparent)' : 'none', }} > Enter to send ยท Shift+Enter new line >, document.body ); };
Create profiles, explain holdings, and diagnose blockers
Quick Actions
Enter to send ยท Shift+Enter new line