import { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { supabase } from '../lib/supabaseClient'; import { tradingRuntime } from '../lib/runtime'; import { Send, X, Bot, User, Check, Loader2, Zap, Copy } from 'lucide-react'; interface ChatMessage { id: number; role: 'user' | 'assistant'; content: string; profileData?: any; action?: string; timestamp: Date; } interface ChatControlProps { profiles: any[]; onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>; } 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: '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, }); // 3D Robot SVG Icon const RobotIcon = ({ size = 32 }: { size?: number }) => ( {/* Antenna */} {/* Head */} {/* Eyes */} {/* Mouth */} {/* Body */} {/* Body detail */} {/* Arms */} ); export const ChatControl = ({ profiles, onApplyProfile }: ChatControlProps) => { 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 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 { data: sessionData } = await supabase.auth.getSession(); const accessToken = sessionData.session?.access_token; if (!accessToken) { throw new Error('Not authenticated'); } const res = await fetch(`${apiUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, body: JSON.stringify({ message: msg, context: 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, })), }), }); 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, 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: `Error: ${err.message}`, timestamp: new Date(), }]); } setIsLoading(false); }; const handleApply = async (msg: ChatMessage) => { if (msg.profileData && msg.action) { const activeDraft = draftProfiles[msg.id] || msg.profileData; const payload = normalizeProfileForApply(activeDraft); const result = await onApplyProfile(msg.action, payload); if (result.success) { setAppliedIds(prev => new Set(prev).add(msg.id)); closeDraftEditor(msg.id); setMessages(prev => [...prev, { id: Date.now(), role: 'assistant', content: msg.action === '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 ${msg.action === '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(); } }; // Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues) if (!isOpen) { return createPortal( setIsOpen(true)} 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: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)', animation: 'fadeIn 0.15s ease-out', }} /> {/* Header */} AI Strategy Assistant Create & manage profiles with natural language setIsOpen(false)} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-500 hover:text-white transition-all" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', }} > {/* Messages */} {messages.map(msg => ( {/* Avatar */} {msg.role === 'user' ? : } {/* Bubble */} {msg.content} {/* Profile preview card */} {msg.profileData && msg.action !== 'explain' && (() => { 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' : 'Update Profile'} copyJson(activeProfileData)} className="text-zinc-600 hover:text-zinc-400 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] bg-[#161722] border border-white/10 text-white outline-none" /> updateDraftField(msg.id, 'allocated_capital', e.target.value)} placeholder="Capital" className="w-full rounded-lg px-2.5 py-1.5 text-[11px] bg-[#161722] border border-white/10 text-white outline-none" /> 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] bg-[#161722] border border-white/10 text-white outline-none" /> 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] bg-[#161722] border border-white/10 text-white outline-none" /> Auto Trading updateDraftField(msg.id, 'is_active', e.target.value === 'true')} className="rounded px-2 py-1 text-[10px] bg-[#0f1017] border border-white/10 text-zinc-300 outline-none" > Active Paused resetDraft(msg)} className="px-2.5 py-1 rounded border border-white/10 text-[9px] font-bold uppercase tracking-wider text-zinc-300 hover:bg-white/5 transition-colors" > Reset ) : null} {!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? ( handleCancel(msg.id)} 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: '#ef4444', borderRight: '1px solid rgba(255,255,255,0.04)', }} > Cancel isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)} 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: '#fbbf24', borderRight: '1px solid rgba(255,255,255,0.04)', }} > {isEditing ? 'Done Editing' : 'Edit Params'} handleApply(msg)} 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: 'linear-gradient(90deg, rgba(0,255,136,0.12), rgba(0,255,136,0.06))', color: '#00ff88', }} > 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)} className="text-left px-3.5 py-3 rounded-xl transition-all" style={{ background: '#161722', border: '1px solid rgba(255,255,255,0.08)', cursor: 'pointer', }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(0,255,136,0.25)'; e.currentTarget.style.background = '#1a1b2a'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'; e.currentTarget.style.background = '#161722'; }} > {action.label} {action.prompt.slice(0, 55)}... ))} )} {isLoading && ( Generating configuration... )} {/* Input area */} setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Describe a strategy profile..." 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={{ background: '#161722', border: '1px solid rgba(255,255,255,0.12)', boxShadow: 'inset 0 2px 6px rgba(0,0,0,0.3)', lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px', color: '#ffffff', caretColor: '#00ff88', }} /> sendMessage()} disabled={!input.trim() || isLoading} 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() ? 'linear-gradient(135deg, #00ff88, #00cc6a)' : 'rgba(255,255,255,0.05)', color: input.trim() ? '#000' : '#52525b', boxShadow: input.trim() ? '0 2px 10px rgba(0,255,136,0.3)' : 'none', }} > Enter to send ยท Shift+Enter new line >, document.body ); };
Create & manage profiles with natural language
Quick Actions
Enter to send ยท Shift+Enter new line