| import React, { useState, useRef, useEffect } from 'react';
|
| import axios from 'axios';
|
| import { Send, ChevronDown, ChevronRight, CheckCircle2, Circle } from 'lucide-react';
|
| import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from 'recharts';
|
|
|
| const guardrails = ["who is", "poem", "joke", "weather", "translate"];
|
| const suggestions = [
|
| "Top billed products",
|
| "Trace billing doc 91150187",
|
| "Explain transaction 91150187",
|
| "Find fraud",
|
| "Incomplete flows",
|
| "Customer revenue",
|
| "Journal entries"
|
| ];
|
|
|
| const ChatPanel = ({ cyInstance, sendRef, width }) => {
|
| const [query, setQuery] = useState("");
|
| const [messages, setMessages] = useState([]);
|
| const [isThinking, setIsThinking] = useState(false);
|
| const endRef = useRef(null);
|
|
|
| useEffect(() => {
|
| if (document.startViewTransition) {
|
| document.startViewTransition(() => {
|
| endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| });
|
| } else {
|
| endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| }
|
| }, [messages, isThinking]);
|
|
|
| const getBadgeColor = (intent) => {
|
| if (intent.includes('Trace')) return '#3b82f6';
|
| if (intent.includes('Error') || intent.includes('Anomaly')) return '#f87171';
|
| if (intent.includes('Aggregat')) return '#22c55e';
|
| if (intent.includes('Lookup')) return '#f59e0b';
|
| if (intent.includes('Scope')) return '#f87171';
|
| if (intent.includes('Fraud')) return '#ef4444';
|
| if (intent.includes('Explain')) return '#8b5cf6';
|
| return '#1a1a1a';
|
| };
|
|
|
|
|
| const API_BASE = '/api';
|
|
|
| const handleSend = async (overrideQ) => {
|
| const text = overrideQ || query;
|
| if (!text.trim() || isThinking) return;
|
|
|
| setQuery("");
|
| setMessages(prev => [...prev, { role: 'user', content: text }]);
|
| setIsThinking(true);
|
|
|
| const qlow = text.toLowerCase();
|
|
|
|
|
| if (guardrails.some(g => qlow.includes(g))) {
|
| setTimeout(() => {
|
| setIsThinking(false);
|
| setMessages(prev => [...prev, {
|
| role: 'system',
|
| intent: '🚫 Out of Scope',
|
| intentColor: '#f87171',
|
| content: "This system only answers questions about the Order to Cash dataset. Please ask about orders, deliveries, billing, or payments."
|
| }]);
|
| }, 800);
|
| return;
|
| }
|
|
|
| try {
|
| const res = await axios.post(`${API_BASE}/query`, { query: text });
|
| const data = res.data;
|
|
|
|
|
| if (cyInstance) {
|
| if (data.highlights && data.highlights.length > 0) {
|
| cyInstance.elements().addClass('dimmed').removeClass('highlighted');
|
| let collection = cyInstance.collection();
|
| data.highlights.forEach(h => {
|
| cyInstance.nodes().forEach(n => {
|
| if (n.data('id') === h || n.data('label').toLowerCase().includes(h.toLowerCase().replace('s',''))) {
|
| n.removeClass('dimmed').addClass('highlighted');
|
| collection = collection.union(n);
|
| n.connectedEdges().removeClass('dimmed');
|
| }
|
| });
|
| });
|
| if (collection.length > 0) {
|
| cyInstance.animate({ fit: { eles: collection, padding: 50 } }, { duration: 800 });
|
| }
|
| } else {
|
|
|
| cyInstance.elements().removeClass('dimmed').removeClass('highlighted');
|
| }
|
| }
|
|
|
| const botMsg = {
|
| role: 'system',
|
| intent: data.intent || "🔍 Lookup",
|
| intentColor: getBadgeColor(data.intent || ""),
|
| content: data.answer || "Found matching records from your ERP system.",
|
| result: data.result,
|
| type: data.type
|
| };
|
|
|
| setIsThinking(false);
|
| setMessages(prev => [...prev, botMsg]);
|
|
|
| } catch (error) {
|
|
|
| setTimeout(() => {
|
| setIsThinking(false);
|
| if(qlow.includes("trace") || qlow.includes("91150187")) {
|
| if (cyInstance) {
|
| cyInstance.elements().addClass('dimmed').removeClass('highlighted');
|
| let c = cyInstance.collection();
|
| ['91150187', '80737721', '740506', '9400635958', '310000108'].forEach(id => {
|
| const n = cyInstance.getElementById(id);
|
| if(n.length) { n.removeClass('dimmed').addClass('highlighted'); n.connectedEdges().removeClass('dimmed'); c = c.union(n); }
|
| });
|
| cyInstance.animate({ fit: { eles: c, padding: 50 } }, { duration: 800 });
|
| }
|
| setMessages(prev => [...prev, {
|
| role: 'system', intent: '🔗 Flow Trace', intentColor: '#3b82f6',
|
| content: "Tracing billing document **91150187**. It originates from Sales Order 740506 via Delivery 80737721 and posts to Journal Entry 9400635958.",
|
| planSteps: ["Lookup BillingDoc 91150187", "Traverse to predecessor (Delivery)", "Traverse to root (Order)"],
|
| sql: "SELECT * FROM billing_document_headers WHERE billingDocument='91150187'"
|
| }]);
|
| } else {
|
| setMessages(prev => [...prev, {
|
| role: 'system', intent: '⚠️ API Error', intentColor: '#f87171',
|
| content: "Backend API is unreachable. Showing mocked offline response."
|
| }]);
|
| }
|
| }, 1000);
|
| }
|
| };
|
|
|
|
|
| useEffect(() => {
|
| if (sendRef) sendRef.current = handleSend;
|
| }, [sendRef]);
|
|
|
|
|
|
|
| const renderFraudList = (data) => {
|
| if (!data || data.length === 0) return null;
|
| return (
|
| <div className="mt-2 space-y-1.5">
|
| {data.slice(0, 10).map((item, i) => (
|
| <div key={i} className="flex items-start gap-2 text-[12px] bg-[#fef2f2] border border-[#fecaca] rounded-md px-2.5 py-1.5">
|
| <span className="shrink-0 mt-0.5">🔴</span>
|
| <div>
|
| <span className="font-semibold">{item.id || item.salesOrder || item.billingDocument || item.journalEntry}</span>
|
| <span className="text-[#991b1b] ml-1">{item.issue}</span>
|
| </div>
|
| </div>
|
| ))}
|
| {data.length > 10 && <div className="text-[11px] text-[#8a8a84]">...and {data.length - 10} more</div>}
|
| </div>
|
| );
|
| };
|
|
|
| const renderFlowTrace = (data) => {
|
| if (!data || data.length === 0) return null;
|
| const r = data[0];
|
|
|
| const steps = [
|
| { id: r.salesOrder, label: 'Order', icon: '📦', color: '#3b82f6' },
|
| { id: r.deliveryDocument, label: 'Delivery', icon: '🚚', color: '#22c55e' },
|
| { id: r.billingDocument, label: 'Billing', icon: '📄', color: '#f59e0b' },
|
| { id: r.journalEntry, label: 'Journal', icon: '📒', color: '#8b5cf6' },
|
| { id: r.paymentDoc, label: 'Payment', icon: '💰', color: '#f87171' }
|
| ].filter(s => s.id && s.id !== 'nan' && String(s.id).toLowerCase() !== 'null');
|
|
|
| return (
|
| <div className="mt-4 flex flex-col gap-3 bg-[#f8fafc] border border-[#e2e8f0] rounded-xl p-3.5 shadow-sm">
|
| <div className="text-[11px] font-bold text-[#64748b] uppercase tracking-wider mb-1 px-1">Transaction Stream</div>
|
| <div className="flex items-center flex-wrap gap-y-4">
|
| {steps.map((step, i) => (
|
| <React.Fragment key={i}>
|
| <div
|
| className="flex items-center gap-2 px-3 py-2 bg-white border border-[#e2e8f0] rounded-lg shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer hover:border-[#3b82f6] transition-all group"
|
| onClick={() => {
|
| if (cyInstance) {
|
| const n = cyInstance.getElementById(String(step.id));
|
| if (n.length) {
|
| cyInstance.elements().addClass('dimmed').removeClass('highlighted');
|
| n.removeClass('dimmed').addClass('highlighted');
|
| n.connectedEdges().removeClass('dimmed').addClass('highlighted');
|
| cyInstance.animate({ fit: { eles: n.union(n.connectedEdges()), padding: 50 } }, { duration: 600 });
|
| }
|
| }
|
| }}
|
| >
|
| <span className="text-lg">{step.icon}</span>
|
| <div className="flex flex-col">
|
| <span className="text-[10px] font-bold text-[#94a3b8] uppercase flex items-center gap-1">
|
| {step.label}
|
| <div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: step.color }}></div>
|
| </span>
|
| <span className="text-[12px] font-mono font-semibold text-[#1e293b] group-hover:text-[#3b82f6]">{step.id}</span>
|
| </div>
|
| </div>
|
| {i < steps.length - 1 && (
|
| <div className="mx-1.5 text-[#cbd5e1] font-bold text-xl animate-pulse">→</div>
|
| )}
|
| </React.Fragment>
|
| ))}
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|
| const COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#f87171', '#8b5cf6'];
|
|
|
| const renderDynamicChart = (type, data) => {
|
| if (!data || data.length === 0 || type === 'table' || type === 'flow') return null;
|
| const keys = Object.keys(data[0]);
|
| if (keys.length < 2) return null;
|
|
|
| const xAxisKey = keys[0];
|
| const yAxisKey = keys[1];
|
|
|
| if (type === 'bar') {
|
| return (
|
| <div className="w-full h-[180px] mt-3 bg-white p-2 rounded border border-[#e2e2dc]">
|
| <ResponsiveContainer width="100%" height="100%">
|
| <BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
| <XAxis dataKey={xAxisKey} tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
|
| <YAxis tick={{ fontSize: 10 }} axisLine={false} tickLine={false} />
|
| <Tooltip cursor={{ fill: '#f5f5f0' }} contentStyle={{ fontSize: '11px', borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }} />
|
| <Bar dataKey={yAxisKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
| </BarChart>
|
| </ResponsiveContainer>
|
| </div>
|
| );
|
| }
|
|
|
| if (type === 'distribution') {
|
| return (
|
| <div className="w-full h-[180px] mt-3 bg-white p-2 rounded border border-[#e2e2dc]">
|
| <ResponsiveContainer width="100%" height="100%">
|
| <PieChart>
|
| <Pie data={data} dataKey={yAxisKey} nameKey={xAxisKey} cx="50%" cy="50%" outerRadius={60} fill="#3b82f6" label={{ fontSize: 10 }}>
|
| {data.map((entry, index) => <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />)}
|
| </Pie>
|
| <Tooltip contentStyle={{ fontSize: '11px', borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }} />
|
| </PieChart>
|
| </ResponsiveContainer>
|
| </div>
|
| );
|
| }
|
| return null;
|
| };
|
| const formatText = (txt) => {
|
| const parts = txt.split(/(\*\*.*?\*\*)/g);
|
| return parts.map((p, i) => {
|
| if (p.startsWith('**') && p.endsWith('**')) return <strong key={i} className="font-semibold text-black">{p.slice(2,-2)}</strong>;
|
| return p;
|
| });
|
| };
|
|
|
| return (
|
| <div
|
| className="border-l border-[#e2e2dc] flex flex-col bg-white shrink-0 shadow-[-4px_0_24px_rgba(0,0,0,0.02)] z-10"
|
| style={{ width: `${width}px` }}
|
| >
|
|
|
| {/* Header */}
|
| <div className="px-5 py-4 border-b border-[#e2e2dc] shrink-0">
|
| <div className="font-semibold text-[15px] text-[#1a1a1a]">Chat with Graph</div>
|
| <div className="text-[#8a8a84] text-[13px] mt-0.5">Order to Cash</div>
|
| </div>
|
|
|
| {/* Intro */}
|
| <div className="px-5 py-4.5 border-b border-[#e2e2dc] flex gap-3 items-start shrink-0">
|
| <div className="w-9 h-9 bg-[#1a1a1a] text-white rounded-full flex items-center justify-center font-bold text-base shrink-0 shadow-sm">D</div>
|
| <div>
|
| <div className="font-bold text-[14px]">Dodge AI</div>
|
| <div className="text-[#8a8a84] text-[12px] mb-1.5">Graph Agent</div>
|
| <div className="text-[13.5px] text-[#1a1a1a] leading-snug">Hi! I can help you analyze the <strong>Order to Cash</strong> process.</div>
|
| </div>
|
| </div>
|
|
|
| {/* Suggestion Chips */}
|
| <div className="px-5 py-3 border-b border-[#e2e2dc] flex flex-wrap gap-1.5 shrink-0 bg-[#fafafa]">
|
| {suggestions.map(s => (
|
| <button key={s} onClick={() => handleSend(s)} className="px-3 py-1.5 rounded-full border border-[#e2e2dc] bg-[#f5f5f0] text-[12px] font-medium text-[#1a1a1a] hover:bg-[#1a1a1a] hover:text-white hover:border-[#1a1a1a] transition-all">
|
| {s}
|
| </button>
|
| ))}
|
| </div>
|
|
|
| {/* Messages */}
|
| <div id="chat-messages" className="flex-1 overflow-y-auto p-5 flex flex-col gap-6">
|
| {messages.map((m, i) => (
|
| m.role === 'user' ? (
|
| <div key={i} className="bubble-user shadow-sm">{m.content}</div>
|
| ) : (
|
| <div key={i} className="flex gap-3">
|
| <div className="w-7 h-7 bg-[#1a1a1a] text-white rounded-full flex items-center justify-center font-bold text-xs shrink-0 mt-1 shadow-sm">D</div>
|
| <div className="bubble-ai shadow-[0_2px_8px_rgba(0,0,0,0.03)] flex-1 min-w-0">
|
| <div className="intent-badge" style={{ backgroundColor: m.intentColor }}>{m.intent}</div>
|
| <div className="text-[13.5px] mt-1 whitespace-pre-wrap">{formatText(m.content)}</div>
|
| {(m.type === 'flow' || m.type === 'explanation') && m.result && renderFlowTrace(m.result)}
|
| {m.result && m.result.length > 0 && m.type && renderDynamicChart(m.type, m.result)}
|
| {m.type === 'fraud' && m.result && renderFraudList(m.result)}
|
| </div>
|
| </div>
|
| )
|
| ))}
|
| {isThinking && (
|
| <div className="flex gap-3">
|
| <div className="w-7 h-7 bg-[#1a1a1a] text-white rounded-full flex items-center justify-center font-bold text-xs shrink-0 mt-1 shadow-sm">D</div>
|
| <div className="bubble-ai shadow-[0_2px_8px_rgba(0,0,0,0.03)] border-[#e2e2dc] self-start py-3.5 px-4 min-w-[60px] flex items-center justify-center">
|
| <div className="dot dot1"></div><div className="dot dot2"></div><div className="dot dot3"></div>
|
| </div>
|
| </div>
|
| )}
|
| <div ref={endRef} />
|
| </div>
|
|
|
| {/* Footer Input */}
|
| <div className="px-5 py-3.5 border-t border-[#e2e2dc] shrink-0 bg-white">
|
| <div className="flex items-center gap-1.5 mb-2.5 text-[12px] text-[#8a8a84]">
|
| <div className={`w-2 h-2 rounded-full shadow-sm ${isThinking ? 'bg-[#f59e0b] shadow-[0_0_4px_#f59e0b] animate-[pulse-amber_1s_infinite]' : 'bg-[#22c55e] shadow-[0_0_4px_#22c55e]'}`}></div>
|
| <div className="font-medium">{isThinking ? 'Dodge AI is thinking...' : 'Dodge AI is awaiting instructions'}</div>
|
| </div>
|
| <div className="flex gap-2">
|
| <textarea
|
| rows={1}
|
| value={query}
|
| onChange={(e) => setQuery(e.target.value)}
|
| onKeyDown={(e) => { if(e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
|
| placeholder="Analyze anything"
|
| className="flex-1 px-3.5 py-2.5 border border-[#e2e2dc] rounded-xl text-[14px] outline-none focus:border-[#1a1a1a] transition-colors resize-none overflow-hidden bg-[#fafafa]"
|
| style={{ minHeight: '42px', maxHeight: '120px' }}
|
| />
|
| <button
|
| onClick={() => handleSend()}
|
| disabled={isThinking || !query.trim()}
|
| className="bg-[#1a1a1a] text-white rounded-xl h-[42px] px-4 hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm flex items-center justify-center shrink-0"
|
| >
|
| <Send size={18} strokeWidth={2.5} className="mr-0.5 ml-[-1px] mb-[-1px]" />
|
| </button>
|
| </div>
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|
| export default ChatPanel;
|
|
|