chatbot-frontend / src /components /ChatPanel.jsx
zaheer140's picture
Upload 17 files
4f5ef41 verified
Raw History Blame Contribute Delete
23.2 kB
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, Bot, User, Info, Wifi, AlertTriangle, CheckCircle2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
const API_BASE = import.meta.env.VITE_API_BASE || '';
const OrchestrationLog = ({ steps }) => {
if (!steps || !Array.isArray(steps)) return null;
return (
<div className="orchestration-log">
<div className="orch-title">
<Info size={10} /> Agent Reasoning & Path
</div>
<ul className="orch-list">
{steps.map((step, i) => (
<li key={i} className="orch-item">
<span className="orch-dot" />
{step}
</li>
))}
</ul>
</div>
);
};
const ResultCards = ({ data }) => {
if (!data || !Array.isArray(data)) return null;
return (
<div className="result-cards">
{data.map((item, idx) => {
const status = item.status || (item.anomaly === 1 ? 'Critical' : 'Normal');
let pillClass = 'pill-green';
let Icon = CheckCircle2;
if (status === 'Critical') { pillClass = 'pill-red'; Icon = AlertTriangle; }
else if (status === 'Warning') { pillClass = 'pill-yellow'; Icon = Info; }
else if (status === 'No Data') { pillClass = 'pill-white'; Icon = Wifi; }
return (
<div key={idx} className={`result-card status-${status.toLowerCase().replace(' ', '')}`}>
<div className="rc-head">
<span className="rc-name">{item.name || item.asset || item.asset_name || 'Asset'}</span>
<span className={`rc-pill ${pillClass}`}>
<Icon size={10} />
{status}
</span>
</div>
<div className="rc-metrics">
{item.value !== undefined && <span>Value: <strong>{item.value}</strong></span>}
{item.latest_mse !== undefined && <span>MSE: <strong>{item.latest_mse.toFixed(5)}</strong></span>}
{item.mse !== undefined && <span>MSE: <strong>{item.mse.toFixed(5)}</strong></span>}
</div>
</div>
);
})}
</div>
);
};
const TrendChartRenderer = ({ chart }) => {
if (!chart || !chart.series || chart.series.length === 0) return null;
const [hoveredPoint, setHoveredPoint] = useState(null);
// Dimensions
const width = 520;
const height = 240;
const paddingLeft = 45;
const paddingRight = 20;
const paddingTop = 25;
const paddingBottom = 35;
// Get all values to compute range
let allValues = [];
chart.series.forEach(s => {
if (s.points) {
s.points.forEach(p => allValues.push(p.value));
}
});
if (allValues.length === 0) return null;
let minVal = Math.min(...allValues);
let maxVal = Math.max(...allValues);
if (minVal === maxVal) {
minVal = minVal - 1;
maxVal = maxVal + 1;
}
const valRange = maxVal - minVal;
const yMin = minVal - valRange * 0.1;
const yMax = maxVal + valRange * 0.1;
// Helper functions for scaling
const getX = (index, total) => {
if (total <= 1) return paddingLeft + (width - paddingLeft - paddingRight) / 2;
return paddingLeft + (index / (total - 1)) * (width - paddingLeft - paddingRight);
};
const getY = (value) => {
return paddingTop + (1 - (value - yMin) / (yMax - yMin)) * (height - paddingTop - paddingBottom);
};
// Gridlines (Y-axis)
const gridCount = 4;
const gridLines = [];
for (let i = 0; i <= gridCount; i++) {
gridLines.push(yMin + (i / gridCount) * (yMax - yMin));
}
// X-axis labels (render up to 5 labels to avoid clutter)
const maxLabelCount = 5;
const totalPoints = chart.series[0]?.points?.length || 0;
const labelIndices = [];
if (totalPoints > 0) {
const step = Math.max(1, Math.floor(totalPoints / maxLabelCount));
for (let i = 0; i < totalPoints; i += step) {
labelIndices.push(i);
}
if (labelIndices[labelIndices.length - 1] !== totalPoints - 1 && totalPoints > 1) {
labelIndices.push(totalPoints - 1);
}
}
const isLine = chart.chart_type === 'line';
return (
<div className="trend-chart-card">
<div className="trend-chart-header">
<div>
<h3 className="trend-chart-title">{chart.title || 'Telemetry Trend'}</h3>
<p className="trend-chart-subtitle">{chart.y_label || 'Values'} over {chart.x_label || 'Time'}</p>
</div>
</div>
<div className="trend-chart-svg-container">
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
{chart.series.map((s, sIdx) => (
<linearGradient key={sIdx} id={`grad-${sIdx}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color || '#6366f1'} stopOpacity="0.25" />
<stop offset="100%" stopColor={s.color || '#6366f1'} stopOpacity="0.01" />
</linearGradient>
))}
</defs>
{/* Horizontal Gridlines & Y Labels */}
{gridLines.map((val, i) => {
const y = getY(val);
return (
<g key={i}>
<line
x1={paddingLeft}
y1={y}
x2={width - paddingRight}
y2={y}
className="trend-chart-grid"
/>
<text
x={paddingLeft - 8}
y={y + 3}
textAnchor="end"
className="trend-chart-label"
>
{val.toFixed(1)}
</text>
</g>
);
})}
{/* X Axis Base Line */}
<line
x1={paddingLeft}
y1={height - paddingBottom}
x2={width - paddingRight}
y2={height - paddingBottom}
className="trend-chart-axis-line"
/>
{/* X Axis Labels */}
{chart.series[0]?.points?.map((p, pIdx) => {
if (!labelIndices.includes(pIdx)) return null;
const x = getX(pIdx, totalPoints);
return (
<g key={pIdx}>
<line
x1={x}
y1={height - paddingBottom}
x2={x}
y2={height - paddingBottom + 4}
className="trend-chart-axis-line"
/>
<text
x={x}
y={height - paddingBottom + 16}
textAnchor="middle"
className="trend-chart-label"
transform={`rotate(-12, ${x}, ${height - paddingBottom + 16})`}
>
{p.label}
</text>
</g>
);
})}
{/* Plot Series */}
{chart.series.map((s, sIdx) => {
if (!s.points || s.points.length === 0) return null;
const pointsCount = s.points.length;
// 1. Line Chart Path
if (isLine) {
let pathD = '';
s.points.forEach((p, pIdx) => {
const x = getX(pIdx, pointsCount);
const y = getY(p.value);
pathD += `${pIdx === 0 ? 'M' : 'L'} ${x} ${y}`;
});
const bottomY = height - paddingBottom;
const firstX = getX(0, pointsCount);
const lastX = getX(pointsCount - 1, pointsCount);
const areaD = `${pathD} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`;
return (
<g key={sIdx}>
{/* Shaded Area Under Line */}
<path
d={areaD}
fill={`url(#grad-${sIdx})`}
className="trend-chart-area"
/>
{/* Glow Stroke Line */}
<path
d={pathD}
stroke={s.color || '#6366f1'}
className="trend-chart-line"
/>
{/* Data Points */}
{s.points.map((p, pIdx) => {
const x = getX(pIdx, pointsCount);
const y = getY(p.value);
return (
<circle
key={pIdx}
cx={x}
cy={y}
r={3.5}
fill="#0f172a"
stroke={s.color || '#6366f1'}
strokeWidth={1.5}
className="trend-chart-point"
onMouseEnter={(e) => {
const rect = e.target.getBoundingClientRect();
const containerRect = e.target.ownerDocument.querySelector('.trend-chart-svg-container').getBoundingClientRect();
setHoveredPoint({
label: p.label,
value: p.value,
seriesLabel: s.label,
color: s.color,
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top
});
}}
onMouseLeave={() => setHoveredPoint(null)}
/>
);
})}
</g>
);
} else {
// 2. Bar Chart Columns
const barWidth = Math.max(4, ((width - paddingLeft - paddingRight) / pointsCount) * 0.55);
const spacing = ((width - paddingLeft - paddingRight) / pointsCount) * 0.45;
return (
<g key={sIdx}>
{s.points.map((p, pIdx) => {
const x = paddingLeft + pIdx * (barWidth + spacing) + spacing / 2;
const y = getY(p.value);
const bottomY = height - paddingBottom;
const barHeight = Math.max(2, bottomY - y);
return (
<rect
key={pIdx}
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={Math.min(3, barWidth / 3)}
fill={s.color || '#6366f1'}
opacity={0.8}
className="trend-chart-bar"
onMouseEnter={(e) => {
const rect = e.target.getBoundingClientRect();
const containerRect = e.target.ownerDocument.querySelector('.trend-chart-svg-container').getBoundingClientRect();
setHoveredPoint({
label: p.label,
value: p.value,
seriesLabel: s.label,
color: s.color,
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top
});
}}
onMouseLeave={() => setHoveredPoint(null)}
/>
);
})}
</g>
);
}
})}
</svg>
{/* Tooltip Overlay */}
{hoveredPoint && (
<div
className="trend-chart-tooltip"
style={{
left: `${(hoveredPoint.x / width) * 100}%`,
top: `${(hoveredPoint.y / height) * 100}%`
}}
>
<span className="trend-tooltip-series" style={{ color: hoveredPoint.color }}>
{hoveredPoint.seriesLabel}
</span>
<span className="trend-tooltip-value">
{hoveredPoint.value.toFixed(2)}
</span>
<span className="trend-tooltip-label">
{hoveredPoint.label}
</span>
</div>
)}
</div>
{/* Legend */}
{chart.series.length > 0 && (
<div className="trend-chart-legend">
{chart.series.map((s, sIdx) => (
<div key={sIdx} className="trend-legend-item">
<span className="trend-legend-color" style={{ backgroundColor: s.color || '#6366f1' }} />
<span>{s.label}</span>
</div>
))}
</div>
)}
</div>
);
};
const MessageBubble = ({ message }) => {
const isAI = message.sender === 'ai';
const results = message.result;
const analysis = message.analysis;
const trendChart = analysis?.trend_chart || (analysis?.trend_charts && analysis.trend_charts[0]);
return (
<motion.div
initial={{ y: 16, opacity: 0, scale: 0.97 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className={`msg-row ${message.sender}`}
>
<div className={`bubble-avatar ${message.sender}`}>
{isAI ? <Bot size={14} /> : <User size={14} />}
</div>
<div className={`bubble ${message.sender}`}>
{isAI ? (
<div className="markdown-content">
<ReactMarkdown>{message.text}</ReactMarkdown>
{message.orchestration && <OrchestrationLog steps={message.orchestration} />}
{trendChart && <TrendChartRenderer chart={trendChart} />}
{results && <ResultCards data={results} />}
</div>
) : (
<p>{message.text}</p>
)}
</div>
</motion.div>
);
};
const ChatPanel = () => {
const [messages, setMessages] = useState([
{
id: 1,
text: "Hello! I am **The Foreman**, your Industrial Multi-Agent Supervisor. I can help with real-time telemetry, historical analysis, and anomaly detection. How can I assist you today?",
sender: 'ai'
}
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const scrollRef = useRef(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, loading]);
const handleSend = async (e) => {
e.preventDefault();
if (!input.trim()) return;
const userMsg = { id: Date.now(), text: input, sender: 'user' };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const { data } = await axios.post(`${API_BASE}/api/v1/chat/message`, { message: input });
const aiMsg = {
id: Date.now() + 1,
text: data.answer || data.reply,
sender: 'ai',
result: data.data,
intent: data.intent,
analysis: data.analysis,
orchestration: data.orchestration
};
setMessages(prev => [...prev, aiMsg]);
} catch (err) {
console.error("Gateway error:", err);
const errorDetail = err.response ? ` (${err.response.status}: ${JSON.stringify(err.response.data)})` : ` (${err.message})`;
setMessages(prev => [...prev, {
id: Date.now(),
text: `The Foreman is currently unable to reach the gateway${errorDetail}. Please check your network connection.`,
sender: 'ai'
}]);
} finally {
setLoading(false);
}
};
return (
<main className="foreman-chat">
{/* Topbar */}
<div className="chat-topbar">
<div className="topbar-avatar">
<Bot size={18} />
</div>
<div className="topbar-meta">
<h2 className="topbar-title">The Foreman</h2>
<span className="topbar-sub">Supervising Industrial Systems</span>
</div>
<div className="topbar-status">
<Wifi size={12} />
All systems reporting
</div>
</div>
{/* Messages */}
<div className="chat-messages" ref={scrollRef}>
{/* Guide Card */}
<div className="guide-card">
<div className="guide-icon"><Info size={18} /></div>
<div className="guide-body">
<p className="guide-title">Operational Guide</p>
<p className="guide-desc">To analyze specific systems, use these naming conventions:</p>
<ul className="guide-list">
<li><strong>Coldrooms:</strong> "coldroom 1" through "coldroom 9"</li>
<li><strong>Refinery:</strong> "tank 1" to "tank 13", no tank 10</li>
</ul>
</div>
</div>
<AnimatePresence>
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
</AnimatePresence>
{loading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="typing-row"
>
<div className="bubble-avatar ai"><Bot size={14} /></div>
<div className="typing-dots">
<span /><span /><span />
</div>
</motion.div>
)}
</div>
{/* Input */}
<div className="chat-input-area">
<form className="input-form" onSubmit={handleSend}>
<input
type="text"
className="input-field"
placeholder="Type a message to The Foreman..."
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="submit" className="send-btn" disabled={loading}>
<Send size={15} />
</button>
</form>
</div>
</main>
);
};
export default ChatPanel;