QuantIQ / frontend /src /components /ChartChatbot.tsx
Karan6124's picture
style: simplify and shorten advisor chat disclaimer message
26a57aa
Raw
History Blame Contribute Delete
22.2 kB
import React, { useState, useEffect, useRef } from 'react';
import { Send, Sparkles, Lock, ArrowRight, Loader2, Trash2 } from 'lucide-react';
import Logo from './Logo';
interface ChartChatbotProps {
ticker: string;
markers: Array<{ price: number; label: string; color: string }>;
activeIndicators: { sma: boolean; ema: boolean; rsi: boolean };
user: any;
onOpenRecharge?: () => void;
liveData?: {
price: number | null;
sma: number | null;
ema: number | null;
rsi: number | null;
};
}
export default function ChartChatbot({ ticker, markers, activeIndicators, user, onOpenRecharge, liveData }: ChartChatbotProps) {
const [messages, setMessages] = useState<Array<{ role: 'user' | 'assistant'; content: string }>>(() => {
const saved = localStorage.getItem(`quantiq_chat_history_${ticker}`);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to parse chat history:', e);
}
}
return [
{
role: 'assistant',
content: `Hello! I am your QuantIQ AI Strategy Advisor. I have analyzed the chart for ${ticker} and loaded your custom reference markers. Ask me anything about your entry/exit levels or risk setup!`
}
];
});
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
// Local quota tracking synchronized with parent user prop
const [localRemaining, setLocalRemaining] = useState<number>(() => user?.messagesRemaining ?? 0);
const [localUsed, setLocalUsed] = useState<number>(() => user?.monthlyMessagesUsed ?? 0);
const [localTier, setLocalTier] = useState<string>(() => user?.subscriptionTier || 'free');
useEffect(() => {
if (user) {
setLocalRemaining(user.messagesRemaining ?? 0);
setLocalUsed(user.monthlyMessagesUsed ?? 0);
setLocalTier(user.subscriptionTier || 'free');
}
}, [user]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const isAdmin = user?.email === 'karanshelar8775@gmail.com';
const isLimitReached = !isAdmin && (
(localTier !== 'pro' && localRemaining <= 0) ||
(localTier === 'pro' && localUsed >= 100)
);
const getQuotaDisplay = () => {
if (isAdmin) return 'Unlimited (Admin)';
if (localTier === 'pro') {
return `${100 - localUsed} left`;
}
return `${localRemaining} left`;
};
// Helper to format text markdown bold/italics, headers, bullets, and clean up stray symbols
const formatMessageContent = (text: string) => {
if (!text) return '';
const lines = text.split('\n');
return lines.map((line, lineIdx) => {
let cleanedLine = line.trim();
// 1. Convert headers (e.g., #### Header or ### Header) to clean bold blocks
const headerMatch = cleanedLine.match(/^(#{1,6})\s*(.*)$/);
if (headerMatch) {
let headerText = headerMatch[2];
headerText = headerText.replace(/\*\*(.*?)\*\*/g, '$1');
headerText = headerText.replace(/\*(.*?)\*/g, '$1');
return (
<h4 key={lineIdx} style={{ margin: '12px 0 6px', fontSize: '13px', fontWeight: 800, color: '#fff', borderBottom: '1px solid rgba(255,255,255,0.05)', paddingBottom: '4px' }}>
{headerText}
</h4>
);
}
// Check if it is a bullet point (must have content after the bullet character)
const isBullet = (cleanedLine.startsWith('-') || cleanedLine.startsWith('*')) && cleanedLine.replace(/^[\s-*]+/, '').trim() !== '';
if (isBullet) {
cleanedLine = cleanedLine.replace(/^[\s-*]+/, '');
}
// Parse bold (**text**) and italic (*text*) segments into react elements
const parts: Array<React.ReactNode> = [];
const formattingRegex = /(\*\*.*?\*\*|\*.*?\*)/g;
const splitSegments = cleanedLine.split(formattingRegex);
splitSegments.forEach((seg, segIdx) => {
if (seg.startsWith('**') && seg.endsWith('**')) {
const inner = seg.slice(2, -2);
parts.push(<strong key={segIdx} style={{ color: 'var(--neon-cyan)', fontWeight: 800 }}>{inner}</strong>);
} else if (seg.startsWith('*') && seg.endsWith('*')) {
const inner = seg.slice(1, -1);
parts.push(<em key={segIdx} style={{ fontStyle: 'italic', color: '#fff' }}>{inner}</em>);
} else {
parts.push(seg);
}
});
const content = parts.length > 0 ? parts : cleanedLine;
if (isBullet) {
return (
<div key={lineIdx} style={{ display: 'flex', gap: '6px', margin: '4px 0 4px 8px', fontSize: '12px', lineHeight: 1.5 }}>
<span style={{ color: 'var(--neon-cyan)' }}></span>
<span style={{ flex: 1 }}>{content}</span>
</div>
);
}
return (
<p key={lineIdx} style={{ margin: cleanedLine === '' ? '8px 0' : '4px 0', minHeight: cleanedLine === '' ? '12px' : 'auto' }}>
{content}
</p>
);
});
};
const adjustHeight = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 100)}px`;
}
};
useEffect(() => {
adjustHeight();
}, [input]);
// Load chat history on ticker change
useEffect(() => {
const saved = localStorage.getItem(`quantiq_chat_history_${ticker}`);
if (saved) {
try {
setMessages(JSON.parse(saved));
return;
} catch (e) {
console.error('Failed to parse chat history on ticker change:', e);
}
}
setMessages([
{
role: 'assistant',
content: `Hello! I am your QuantIQ AI Strategy Advisor. I have analyzed the chart for ${ticker} and loaded your custom reference markers. Ask me anything about your entry/exit levels or risk setup!`
}
]);
}, [ticker]);
// Save chat history to localStorage and scroll to bottom
useEffect(() => {
localStorage.setItem(`quantiq_chat_history_${ticker}`, JSON.stringify(messages));
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, ticker]);
const handleSendMessage = async (e?: React.FormEvent | React.KeyboardEvent) => {
if (e) e.preventDefault();
if (!input.trim() || loading || isLimitReached) return;
const userMessage = input.trim();
setInput('');
if (textareaRef.current) {
textareaRef.current.style.height = '38px';
}
setLoading(true);
const updatedMessages = [...messages, { role: 'user' as const, content: userMessage }];
setMessages(updatedMessages);
try {
const response = await fetch(`${API_URL}/api/v1/analyst/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('quantiq_jwt')}`
},
body: JSON.stringify({
ticker,
message: userMessage,
history: updatedMessages.slice(0, -1),
markers,
activeIndicators,
currentPrice: liveData?.price || null,
smaValue: liveData?.sma || null,
emaValue: liveData?.ema || null,
rsiValue: liveData?.rsi || null
})
});
if (response.ok) {
const data = await response.json();
setMessages(prev => [...prev, { role: 'assistant', content: data.response }]);
// Update local limits state
if (data.subscription_tier !== undefined) {
setLocalTier(data.subscription_tier);
setLocalRemaining(data.messages_remaining ?? 0);
setLocalUsed(data.monthly_messages_used ?? 0);
}
} else {
const errData = await response.json();
const errMessage = errData.detail || "Quota exhausted or query error. Check your subscription.";
setMessages(prev => [...prev, { role: 'assistant', content: `Sorry, I encountered an issue: ${errMessage}` }]);
}
} catch (err) {
console.error('Failed to chat with AI analyst:', err);
setMessages(prev => [...prev, { role: 'assistant', content: "Failed to connect to the AI Strategy engine. Verify your internet connection." }]);
} finally {
setLoading(false);
}
};
return (
<div
className="glass-panel"
style={{
width: '340px',
display: 'flex',
flexDirection: 'column',
height: '100%',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: '16px',
overflow: 'hidden',
background: 'rgba(8, 9, 16, 0.9)',
backdropFilter: 'blur(30px)',
zIndex: 10,
boxShadow: '0 12px 48px rgba(0, 0, 0, 0.6), inset 0 0 20px rgba(0, 242, 254, 0.02)',
position: 'relative'
}}
>
{/* Clear Chat Confirmation Overlay */}
{showConfirm && (
<div className="custom-modal-overlay">
<div className="custom-modal-content glass-panel liquid-glass animate-fade-rise" style={{ border: '1px solid rgba(255, 23, 68, 0.3)', maxWidth: '280px', padding: '24px' }}>
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '12px', textAlign: 'left' }}>
Confirm Deletion
</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '24px', lineHeight: '150%', textAlign: 'left' }}>
Are you sure you want to clear the chat history for <strong style={{ color: 'var(--neon-cyan)' }}>{ticker}</strong> from your screen?
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowConfirm(false)}
className="modal-cancel-btn"
>
Cancel
</button>
<button
onClick={() => {
localStorage.removeItem(`quantiq_chat_history_${ticker}`);
setMessages([
{
role: 'assistant',
content: `Hello! I am your QuantIQ AI Strategy Advisor. I have analyzed the chart for ${ticker} and loaded your custom reference markers. Ask me anything about your entry/exit levels or risk setup!`
}
]);
setShowConfirm(false);
}}
className="modal-confirm-btn"
>
Remove
</button>
</div>
</div>
</div>
)}
{/* Background Cyber Glowing Orbs */}
<div style={{
position: 'absolute',
top: '-40px',
right: '-40px',
width: '160px',
height: '160px',
background: 'radial-gradient(circle, rgba(161, 84, 255, 0.08) 0%, transparent 70%)',
pointerEvents: 'none',
zIndex: 0
}} />
<div style={{
position: 'absolute',
bottom: '60px',
left: '-40px',
width: '160px',
height: '160px',
background: 'radial-gradient(circle, rgba(0, 242, 254, 0.06) 0%, transparent 70%)',
pointerEvents: 'none',
zIndex: 0
}} />
{/* Header */}
<div
style={{
padding: '16px',
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'linear-gradient(90deg, rgba(0, 242, 254, 0.05) 0%, rgba(161, 84, 255, 0.05) 100%)'
}}
>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'rgba(0, 242, 254, 0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid rgba(0, 242, 254, 0.2)'
}}>
<Logo size={18} />
</div>
<div style={{ textAlign: 'left' }}>
<h3 style={{ margin: 0, fontSize: '13px', fontWeight: 700, color: '#fff', display: 'flex', alignItems: 'center', gap: '4px' }}>
Quant AI Advisor <Sparkles size={11} color="var(--neon-violet)" />
</h3>
<span style={{ fontSize: '10px', color: 'var(--text-secondary)' }}>Real-time AI Copilot & Strategy Engine</span>
</div>
{/* Quota display badge */}
<span
style={{
marginLeft: 'auto',
fontSize: '9px',
fontWeight: 700,
background: isLimitReached ? 'rgba(239, 68, 68, 0.15)' : 'rgba(16, 185, 129, 0.15)',
color: isLimitReached ? '#ef4444' : '#10b981',
padding: '2px 6px',
borderRadius: '4px',
border: `1px solid ${isLimitReached ? 'rgba(239, 68, 68, 0.25)' : 'rgba(16, 185, 129, 0.25)'}`
}}
>
{getQuotaDisplay()}
</span>
{/* Clear Chat Button */}
<button
onClick={() => setShowConfirm(true)}
style={{
background: 'none',
border: 'none',
color: 'rgba(255, 255, 255, 0.4)',
cursor: 'pointer',
padding: '6px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
outline: 'none'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#ef4444';
e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.4)';
e.currentTarget.style.backgroundColor = 'transparent';
}}
title="Clear Chat from screen"
>
<Trash2 size={13} />
</button>
</div>
{/* Message Feed */}
<div
style={{
flex: 1,
padding: '16px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '12px',
position: 'relative'
}}
>
{/* Faint Logo Watermark Background */}
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
opacity: 0.025,
pointerEvents: 'none',
zIndex: 0
}}>
<Logo size={130} />
</div>
{messages.map((msg, index) => {
const isUser = msg.role === 'user';
return (
<div
key={index}
style={{
alignSelf: isUser ? 'flex-end' : 'flex-start',
maxWidth: '85%',
background: isUser
? 'linear-gradient(135deg, rgba(0, 242, 254, 0.15) 0%, rgba(0, 242, 254, 0.02) 100%)'
: 'linear-gradient(135deg, rgba(161, 84, 255, 0.1) 0%, rgba(161, 84, 255, 0.02) 100%)',
border: isUser
? '1px solid rgba(0, 242, 254, 0.25)'
: '1px solid rgba(161, 84, 255, 0.18)',
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
padding: '10px 14px',
color: isUser ? '#fff' : 'rgba(255, 255, 255, 0.9)',
fontSize: '12px',
lineHeight: 1.5,
textAlign: 'left',
position: 'relative',
zIndex: 1,
boxShadow: isUser
? '0 0 12px rgba(0, 242, 254, 0.1)'
: '0 0 12px rgba(161, 84, 255, 0.05)'
}}
>
<div style={{ whiteSpace: 'pre-wrap' }}>
{formatMessageContent(msg.content)}
</div>
</div>
);
})}
{loading && (
<div
style={{
alignSelf: 'flex-start',
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.05)',
borderRadius: '12px 12px 12px 2px',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
color: 'var(--text-muted)'
}}
>
<Loader2 size={12} className="animate-spin" /> Analyzing Quant AI Strategy...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input or Lock overlay */}
<div
style={{
padding: '12px 16px',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
background: 'rgba(10, 11, 20, 0.5)'
}}
>
{isLimitReached ? (
<div
className="animate-fade"
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '6px 0',
textAlign: 'center'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', color: '#ef4444', fontSize: '12px', fontWeight: 700 }}>
<Lock size={12} /> Message Limit Reached
</div>
<p style={{ margin: 0, fontSize: '10px', color: 'var(--text-secondary)', lineHeight: 1.4 }}>
Unlock high-accuracy Wall Street strategies and indicator calculations.
</p>
<button
onClick={() => {
sessionStorage.setItem('quantiq_restore_maximized_chart', 'true');
onOpenRecharge?.();
}}
style={{
width: '100%',
padding: '6px 12px',
borderRadius: '8px',
background: 'linear-gradient(135deg, var(--neon-cyan) 0%, var(--neon-violet) 100%)',
border: 'none',
color: '#000',
fontWeight: 700,
fontSize: '11px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'opacity 0.2s'
}}
>
Upgrade Plan <ArrowRight size={11} />
</button>
</div>
) : (
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
<textarea
ref={textareaRef}
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(e);
}
}}
placeholder="Ask Advisor about markers..."
disabled={loading}
style={{
flex: 1,
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '16px',
padding: '10px 14px',
color: '#fff',
fontSize: '12px',
outline: 'none',
resize: 'none',
lineHeight: 1.4,
maxHeight: '100px',
minHeight: '38px',
height: '38px',
overflowY: 'auto',
transition: 'border-color 0.2s, box-shadow 0.2s',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.2)',
fontFamily: 'inherit'
}}
onFocus={(e) => {
e.target.style.borderColor = 'rgba(0, 242, 254, 0.4)';
e.target.style.boxShadow = '0 0 12px rgba(0, 242, 254, 0.15), inset 0 1px 2px rgba(0,0,0,0.2)';
}}
onBlur={(e) => {
e.target.style.borderColor = 'rgba(255, 255, 255, 0.08)';
e.target.style.boxShadow = 'inset 0 1px 2px rgba(0,0,0,0.2)';
}}
/>
<button
type="submit"
disabled={!input.trim() || loading}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: (!input.trim() || loading)
? 'rgba(255, 255, 255, 0.05)'
: 'linear-gradient(135deg, var(--neon-cyan) 0%, var(--neon-violet) 100%)',
color: (!input.trim() || loading) ? 'rgba(255,255,255,0.3)' : '#000',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (!input.trim() || loading) ? 'not-allowed' : 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: (!input.trim() || loading) ? 'none' : '0 4px 12px rgba(0, 242, 254, 0.3)',
transform: 'scale(1)'
}}
onMouseEnter={(e) => {
if (input.trim() && !loading) {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0, 242, 254, 0.5)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = (!input.trim() || loading) ? 'none' : '0 4px 12px rgba(0, 242, 254, 0.3)';
}}
>
<Send size={14} />
</button>
</form>
)}
{/* Disclaimer */}
<div style={{
padding: '6px 14px 10px',
textAlign: 'center',
fontSize: '9px',
color: 'rgba(255, 255, 255, 0.25)',
lineHeight: 1.5,
borderTop: '1px solid rgba(255, 255, 255, 0.03)'
}}>
⚠️ QuantIQ AI may make errors.
</div>
</div>
</div>
);
}