import { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { tradingRuntime } from '../lib/runtime'; import { getPlatformAccessToken } from '../lib/authSession'; import { createRequestId } from '../../../shared/request-id.js'; import { Send, X, Bot, User, Check, Loader2, Zap, Copy } from 'lucide-react'; import { Button } from './ui/button'; import { cn } from '../lib/utils'; 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 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.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(); } }; const assistantTint = 'var(--accent-soft)'; const panelStyle: React.CSSProperties = { background: 'var(--card)', border: '1px solid var(--border)', boxShadow: '0 25px 80px rgba(0,0,0,0.22)', }; 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)} 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(15, 23, 42, 0.45)', backdropFilter: 'blur(6px)', animation: 'fadeIn 0.15s ease-out', }} /> {/* Header */} AI Strategy Assistant Create & manage profiles with natural language setIsOpen(false)} variant="ghost" size="icon" className="h-8 w-8 rounded-lg" > {/* 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-[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} > Active 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)} 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)} 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 var(--border)', }} > {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: '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)} 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 => { e.currentTarget.style.borderColor = 'var(--ring)'; e.currentTarget.style.background = 'var(--accent-soft)'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.background = 'var(--card)'; }} > {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={{ ...inputStyle, lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px' }} /> 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() ? '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 & manage profiles with natural language
Quick Actions
Enter to send ยท Shift+Enter new line