Spaces:
Sleeping
Sleeping
Download src/components/ChatPanel.jsx from zaheer140/chatbot-frontend: direct link, hf CLI and curl.
- Browser
- Download file 23.2 kB
-
https://huggingface.co/spaces/zaheer140/chatbot-frontend/resolve/main/src/components/ChatPanel.jsx
- Command line
-
hf download hf://spaces/zaheer140/chatbot-frontend/src/components/ChatPanel.jsx
-
curl -L -o ChatPanel.jsx https://huggingface.co/spaces/zaheer140/chatbot-frontend/resolve/main/src/components/ChatPanel.jsx
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; |