Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { useAgent } from '../contexts/AgentContext'; | |
| import ReportRenderer from './ReportRenderer'; | |
| import { | |
| Box, | |
| Typography, | |
| IconButton, | |
| Avatar, | |
| Paper, | |
| TextField, | |
| InputAdornment, | |
| List, | |
| ListItem, | |
| ListItemAvatar, | |
| Chip, | |
| } from '@mui/material'; | |
| import SendIcon from '@mui/icons-material/Send'; | |
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |
| import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; | |
| import { jsPDF } from 'jspdf'; | |
| import { | |
| Document, | |
| Packer, | |
| Paragraph, | |
| ImageRun, | |
| Header as DocxHeader, | |
| AlignmentType, | |
| HeadingLevel, | |
| Table as DocxTable, | |
| TableRow as DocxTableRow, | |
| TableCell as DocxTableCell, | |
| WidthType, | |
| TextRun, | |
| BorderStyle, | |
| } from 'docx'; | |
| interface ChatInterfaceProps { | |
| onClose?: () => void; | |
| } | |
| const ChatInterface: React.FC<ChatInterfaceProps> = ({ onClose }) => { | |
| const { selectedAgent, setSelectedAgent } = useAgent(); | |
| type QuickReplyButton = { title: string; payload: string }; | |
| type CustomMultiSelect = { | |
| type: 'multi_select_chips'; | |
| options: string[]; | |
| columns?: number; | |
| hint?: string; | |
| selections?: string[]; // maintained client-side | |
| }; | |
| // Branding palette + tokens inspired by your screenshot | |
| const BRAND = { | |
| gradientFrom: '#FF6B00', | |
| gradientTo: '#FF3D00', | |
| headerBg: 'linear-gradient(135deg, #FF6B00 0%, #FF3D00 100%)', | |
| headerShadow: '0 10px 24px rgba(255, 80, 0, 0.25)', | |
| windowBg: '#F6F7FB', | |
| surface: '#FFFFFF', | |
| border: '#FFE0D1', | |
| botBubble: '#FFFFFF', | |
| userBubble: '#FFE9DF', | |
| userText: '#7A3D2B', | |
| inputBg: '#FFF4EE', | |
| inputBorder: '#FFC9B3', | |
| placeholder: '#9C6B5E', | |
| sendBtn: '#FA8C6A', | |
| sendBtnHover: '#FF7A50', | |
| chipBg: '#FFE4D3', | |
| chipText: '#7A3D2B', | |
| }; | |
| // Helper to push a bot notice message after an operation completes | |
| const pushBotNotice = (text: string) => | |
| setMessages(prev => [...prev, { sender: 'bot', text }]); | |
| type ChatMessage = { | |
| sender: 'user' | 'bot'; | |
| text?: string; | |
| buttons?: QuickReplyButton[]; | |
| custom?: CustomMultiSelect; | |
| json_message?: any; // pass-through for backend control messages | |
| }; | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [inputValue, setInputValue] = useState(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const chatContainerRef = useRef<HTMLDivElement>(null); | |
| const lastReportRef = useRef<HTMLDivElement | null>(null); | |
| const reportRefs = useRef<Map<number, HTMLDivElement>>(new Map()); | |
| // Unique sender per agent session to avoid reusing old Rasa tracker state | |
| const senderRef = useRef<string>(''); | |
| // Track previous and last REST responses so we can download the prior one on demand | |
| const lastResponseRef = useRef<any>(null); | |
| const prevResponseRef = useRef<any>(null); | |
| // Static public paths for logos (put files in /public) | |
| const YUGEN_LOGO_SRC = '/yugensys.png'; | |
| const BIZ_LOGO_SRC = '/BizInsaights.png'; // NOTE: fixed spelling | |
| // Initialize a stable sender id once | |
| useEffect(() => { | |
| if (!senderRef.current) { | |
| senderRef.current = | |
| (crypto as any)?.randomUUID?.() ? `web-${(crypto as any).randomUUID()}` : `web-${Math.random().toString(36).slice(2, 10)}`; | |
| } | |
| }, []); | |
| // Show a "thinking..." message when loading | |
| useEffect(() => { | |
| if (isLoading) { | |
| const loadingText = selectedAgent?.id === 'lexa-legal' | |
| ? 'Lexa is thinking...' | |
| : 'BizInsights is thinking...'; | |
| setMessages(prev => { | |
| const alreadyShown = prev.some(m => m.text === loadingText); | |
| if (alreadyShown) return prev; | |
| return [...prev, { sender: 'bot', text: loadingText }]; | |
| }); | |
| } else { | |
| setMessages(prev => prev.filter(m => | |
| m.text !== 'BizInsights is thinking...' && | |
| m.text !== 'Lexa is thinking...' | |
| )); | |
| } | |
| }, [isLoading, selectedAgent]); | |
| // Util: download an object as a .json file | |
| const downloadJson = (obj: any, filename?: string) => { | |
| try { | |
| const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = filename || `response-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| URL.revokeObjectURL(url); | |
| sendMessage('/research_complete'); | |
| pushBotNotice('✅ JSON file downloaded'); | |
| } catch (e) { | |
| console.error('[JSON]Failed to download JSON: ', e); | |
| } | |
| }; | |
| // ---- PDF: helpers (fetch + downscale) ---- | |
| const fetchAsDataURL = async (src: string): Promise<string> => { | |
| const res = await fetch(src, { credentials: 'include', cache: 'force-cache' }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${src}`); | |
| const blob = await res.blob(); | |
| return await new Promise<string>((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result as string); | |
| reader.readAsDataURL(blob); | |
| }); | |
| }; | |
| const downscaleDataURL = async ( | |
| dataUrl: string, | |
| maxEdgePx: number, | |
| mime: 'image/jpeg' | 'image/png', | |
| quality: number | |
| ): Promise<string> => { | |
| const img = await new Promise<HTMLImageElement>((resolve, reject) => { | |
| const i = new Image(); | |
| i.onload = () => resolve(i); | |
| i.onerror = () => reject(new Error('Image decode failed')); | |
| i.src = dataUrl; | |
| }); | |
| const naturalW = (img as any).naturalWidth || img.width; | |
| const naturalH = (img as any).naturalHeight || img.height; | |
| const longest = Math.max(naturalW, naturalH); | |
| const scale = longest > maxEdgePx ? maxEdgePx / longest : 1; | |
| const w = Math.max(1, Math.round(naturalW * scale)); | |
| const h = Math.max(1, Math.round(naturalH * scale)); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; | |
| canvas.height = h; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) throw new Error('Canvas 2D context unavailable'); | |
| if (mime === 'image/jpeg') { | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillRect(0, 0, w, h); | |
| } | |
| ctx.drawImage(img, 0, 0, w, h); | |
| return canvas.toDataURL(mime, quality); | |
| }; | |
| // --------- Rasa helpers (NEW) ---------- | |
| // Map Rasa REST payload to our ChatMessage array | |
| const mapRasaToMessages = (data: any[]): ChatMessage[] => { | |
| if (!Array.isArray(data)) return []; | |
| return data | |
| .map((m: any): ChatMessage | null => { | |
| let text = typeof m?.text === 'string' ? m.text : undefined; | |
| const buttons = Array.isArray(m?.buttons) | |
| ? m.buttons | |
| .filter((b: any) => typeof b?.title === 'string' && typeof b?.payload === 'string') | |
| .map((b: any) => ({ title: b.title as string, payload: b.payload as string })) | |
| : undefined; | |
| // Pass-through known custom blocks | |
| let custom: ChatMessage['custom'] = undefined; | |
| if (m?.custom && typeof m.custom === 'object' && m.custom.type === 'multi_select_chips') { | |
| const opts = Array.isArray(m.custom.options) | |
| ? m.custom.options.filter((o: any) => typeof o === 'string') | |
| : []; | |
| custom = { | |
| type: 'multi_select_chips', | |
| options: opts, | |
| columns: typeof m.custom.columns === 'number' ? m.custom.columns : undefined, | |
| hint: typeof m.custom.hint === 'string' ? m.custom.hint : undefined, | |
| selections: [], | |
| }; | |
| } | |
| // Control messages | |
| let json_message = m?.json_message; | |
| if (!json_message && m?.custom && typeof m.custom === 'object' && typeof m.custom.action === 'string') { | |
| json_message = m.custom; | |
| } | |
| const action = json_message?.action; | |
| if (action === 'json:download' || action === 'pdf:download' || action === 'docx:download') { | |
| text = undefined; // suppress bubble for control actions | |
| } | |
| if (!text && (!buttons || buttons.length === 0) && !custom && !json_message) return null; | |
| return { sender: 'bot', text, buttons, custom, json_message }; | |
| }) | |
| .filter(Boolean) as ChatMessage[]; | |
| }; | |
| // ---- PDF: renderer with logos + selectable text ---- | |
| const generateReportPdf = async ({ | |
| content, | |
| title, | |
| yugenLogoSrc = YUGEN_LOGO_SRC, | |
| bizLogoSrc = BIZ_LOGO_SRC, | |
| logoMime = 'image/jpeg' as 'image/jpeg' | 'image/png', | |
| maxLogoEdgePx = 600, | |
| jpegQuality = 0.82, | |
| }: { | |
| content: string; | |
| title?: string; | |
| yugenLogoSrc?: string; | |
| bizLogoSrc?: string; | |
| logoMime?: 'image/jpeg' | 'image/png'; | |
| maxLogoEdgePx?: number; | |
| jpegQuality?: number; | |
| }): Promise<string> => { | |
| console.info('[PDF] Generating...', { yugenLogoSrc, bizLogoSrc, logoMime }); | |
| const doc = new jsPDF({ unit: 'mm', format: 'a4', compress: true }); | |
| const pageWidth = doc.internal.pageSize.getWidth(); | |
| const pageHeight = doc.internal.pageSize.getHeight(); | |
| const margin = 15; | |
| const logoHeight = 15; // mm | |
| const logoWidth = 40; // mm | |
| const contentWidth = pageWidth - 2 * margin; | |
| // Title: derive from first markdown header if not provided | |
| const derivedTitle = (() => { | |
| const firstHeader = (content || '') | |
| .split('\n') | |
| .map(s => s.trim()) | |
| .find(s => s.startsWith('# ')); | |
| return title || (firstHeader ? firstHeader.replace(/^#\s+/, '') : 'Report'); | |
| })(); | |
| // Load + downscale logos | |
| let yugenDataUrl: string | null = null; | |
| let bizDataUrl: string | null = null; | |
| try { | |
| const yugenWithCacheBust = `${yugenLogoSrc}?t=${Date.now()}`; | |
| const yugenRaw = await fetchAsDataURL(yugenWithCacheBust); | |
| yugenDataUrl = await downscaleDataURL(yugenRaw, maxLogoEdgePx, 'image/png', 1.0); | |
| } catch (e) { | |
| console.warn('[PDF] Yugen logo load failed, will skip:', yugenLogoSrc, e); | |
| } | |
| try { | |
| const bizWithCacheBust = `${bizLogoSrc}?t=${Date.now()}`; | |
| const bizRaw = await fetchAsDataURL(bizWithCacheBust); | |
| bizDataUrl = await downscaleDataURL(bizRaw, maxLogoEdgePx, 'image/png', 1.0); | |
| } catch (e) { | |
| console.warn('[PDF] Biz logo load failed, will skip:', bizLogoSrc, e); | |
| } | |
| const YUGEN_ALIAS = 'YUGEN_LOGO'; | |
| const BIZ_ALIAS = 'BIZ_LOGO'; | |
| let embeddedOnce = false; | |
| let cursorY = margin + logoHeight + 24; | |
| const baseLine = 6; // mm baseline leading | |
| const addHeader = () => { | |
| const fmt = logoMime === 'image/png' ? 'PNG' : 'JPEG'; | |
| if (yugenDataUrl) { | |
| if (!embeddedOnce) { | |
| doc.addImage(yugenDataUrl, fmt, margin, margin, logoWidth, logoHeight, YUGEN_ALIAS); | |
| } else { | |
| doc.addImage(YUGEN_ALIAS, fmt, margin, margin, logoWidth, logoHeight); | |
| } | |
| } | |
| if (bizDataUrl) { | |
| const x = pageWidth - margin - logoWidth; | |
| if (!embeddedOnce) { | |
| doc.addImage(bizDataUrl, fmt, x, margin, logoWidth, logoHeight, BIZ_ALIAS); | |
| } else { | |
| doc.addImage(BIZ_ALIAS, fmt, x, margin, logoWidth, logoHeight); | |
| } | |
| } | |
| embeddedOnce = true; | |
| doc.setDrawColor(200); | |
| doc.setLineWidth(0.3); | |
| doc.line(margin, margin + logoHeight + 4, pageWidth - margin, margin + logoHeight + 4); | |
| }; | |
| const ensurePage = (nextBlockHeight = 0) => { | |
| if (cursorY + nextBlockHeight > pageHeight - margin) { | |
| doc.addPage(); | |
| addHeader(); | |
| cursorY = margin + logoHeight + 10; | |
| } | |
| }; | |
| // First page header + title | |
| addHeader(); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.setFontSize(18); | |
| doc.text(derivedTitle, pageWidth / 2, margin + logoHeight + 14, { align: 'center' }); | |
| doc.setFont('helvetica', 'normal'); | |
| doc.setFontSize(12); | |
| // Light markdown handling | |
| const lines = (content || '').split('\n'); | |
| let inCode = false; | |
| const codeLines: string[] = []; | |
| const flushCode = () => { | |
| if (!inCode || codeLines.length === 0) return; | |
| doc.setFont('courier', 'normal'); | |
| const lh = 5; | |
| for (const cl of codeLines) { | |
| const wrapped = doc.splitTextToSize(cl, contentWidth); | |
| for (const w of wrapped) { | |
| ensurePage(lh); | |
| doc.text(w, margin, cursorY); | |
| cursorY += lh; | |
| } | |
| } | |
| doc.setFont('helvetica', 'normal'); | |
| inCode = false; | |
| codeLines.length = 0; | |
| cursorY += 1.5; | |
| }; | |
| for (let raw of lines) { | |
| const line = raw ?? ''; | |
| const t = line.trim(); | |
| if (t.startsWith('```')) { | |
| if (inCode) flushCode(); | |
| else { inCode = true; codeLines.length = 0; } | |
| continue; | |
| } | |
| if (inCode) { codeLines.push(line); continue; } | |
| if (!t) { cursorY += baseLine; continue; } | |
| if (t.startsWith('# ')) { | |
| const txt = t.replace(/^#\s+/, ''); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.setFontSize(16); | |
| const wrapped = doc.splitTextToSize(txt, contentWidth); | |
| ensurePage(baseLine * wrapped.length + 4); | |
| for (const w of wrapped) { doc.text(w, margin, cursorY); cursorY += baseLine; } | |
| cursorY += 2; | |
| doc.setFont('helvetica', 'normal'); | |
| doc.setFontSize(12); | |
| continue; | |
| } | |
| if (t.startsWith('## ')) { | |
| const txt = t.replace(/^##\s+/, ''); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.setFontSize(14); | |
| const wrapped = doc.splitTextToSize(txt, contentWidth); | |
| ensurePage(baseLine * wrapped.length + 2); | |
| for (const w of wrapped) { doc.text(w, margin, cursorY); cursorY += baseLine; } | |
| cursorY += 1; | |
| doc.setFont('helvetica', 'normal'); | |
| doc.setFontSize(12); | |
| continue; | |
| } | |
| if (t.startsWith('### ')) { | |
| const txt = t.replace(/^###\s+/, ''); | |
| doc.setFont('helvetica', 'bold'); | |
| doc.setFontSize(12); | |
| const wrapped = doc.splitTextToSize(txt, contentWidth); | |
| ensurePage(baseLine * wrapped.length + 1); | |
| for (const w of wrapped) { doc.text(w, margin, cursorY); cursorY += baseLine; } | |
| doc.setFont('helvetica', 'normal'); | |
| continue; | |
| } | |
| if (/^(\* |- |• )/.test(t)) { | |
| const bulletText = t.replace(/^(\* |- |• )/, ''); | |
| const bulletIndent = 6; | |
| const textX = margin + bulletIndent; | |
| const wrapWidth = contentWidth - bulletIndent; | |
| const wrapped = doc.splitTextToSize(bulletText, wrapWidth); | |
| ensurePage(baseLine * wrapped.length); | |
| doc.text('•', margin + 1, cursorY); | |
| doc.text(wrapped[0], textX, cursorY); | |
| cursorY += baseLine; | |
| for (let k = 1; k < wrapped.length; k++) { | |
| ensurePage(baseLine); | |
| doc.text(wrapped[k], textX, cursorY); | |
| cursorY += baseLine; | |
| } | |
| continue; | |
| } | |
| const wrapped = doc.splitTextToSize(line, contentWidth); | |
| ensurePage(baseLine * wrapped.length); | |
| for (const w of wrapped) { doc.text(w, margin, cursorY); cursorY += baseLine; } | |
| } | |
| if (inCode) flushCode(); | |
| const filename = `report-${new Date().toISOString().replace(/[:.]/g, '-')}.pdf`; | |
| // 🟢 Trigger file download | |
| doc.save(filename); | |
| // Reuse your pipeline so the UI updates and isLoading is handled | |
| await sendMessage('/research_complete'); | |
| return filename; | |
| }; | |
| // ---- DOCX: Generator with logos + selectable text ---- | |
| const generateReportDocx = async ({ | |
| content, | |
| title, | |
| yugenLogoSrc = YUGEN_LOGO_SRC, | |
| bizLogoSrc = BIZ_LOGO_SRC, | |
| }: { | |
| content: string; | |
| title?: string; | |
| yugenLogoSrc?: string; | |
| bizLogoSrc?: string; | |
| }): Promise<string> => { | |
| console.info('[DOCX] Generating text-based document...'); | |
| // Helper to fetch image as ArrayBuffer for the docx library | |
| const fetchImageAsArrayBuffer = async (src: string): Promise<ArrayBuffer | null> => { | |
| try { | |
| const res = await fetch(src, { credentials: 'include', cache: 'force-cache' }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} for ${src}`); | |
| return await res.arrayBuffer(); | |
| } catch (e) { | |
| console.warn(`[DOCX] Image load failed for ${src}:`, e); | |
| return null; | |
| } | |
| }; | |
| const [yugenLogoBuffer, bizLogoBuffer] = await Promise.all([ | |
| fetchImageAsArrayBuffer(yugenLogoSrc), | |
| fetchImageAsArrayBuffer(bizLogoSrc), | |
| ]); | |
| const headerChildren: (Paragraph | DocxTable)[] = []; | |
| if (yugenLogoBuffer && bizLogoBuffer) { | |
| headerChildren.push( | |
| new DocxTable({ | |
| width: { size: '100%', type: WidthType.PERCENTAGE }, | |
| columnWidths: [2500, 5000, 2500], | |
| borders: { top: { style: 'none' }, bottom: { style: 'none' }, left: { style: 'none' }, right: { style: 'none' } }, | |
| rows: [ | |
| new DocxTableRow({ | |
| children: [ | |
| new DocxTableCell({ | |
| children: [new Paragraph({ | |
| children: [new ImageRun({ | |
| data: yugenLogoBuffer, | |
| transformation: { width: 160, height: 60 }, | |
| type: 'png', // or 'jpeg' based on your image type | |
| })] | |
| })], | |
| borders: { top: { style: 'none' }, bottom: { style: 'none' }, left: { style: 'none' }, right: { style: 'none' } }, | |
| }), | |
| new DocxTableCell({ children: [new Paragraph('')], borders: { top: { style: 'none' }, bottom: { style: 'none' }, left: { style: 'none' }, right: { style: 'none' } } }), | |
| new DocxTableCell({ | |
| children: [new Paragraph({ | |
| alignment: AlignmentType.RIGHT, children: [new ImageRun({ | |
| data: bizLogoBuffer, | |
| transformation: { width: 160, height: 60 }, | |
| type: 'png', // or 'jpeg' based on your image type | |
| })] | |
| })], | |
| borders: { top: { style: 'none' }, bottom: { style: 'none' }, left: { style: 'none' }, right: { style: 'none' } }, | |
| }), | |
| ], | |
| }), | |
| ], | |
| }) | |
| ); | |
| } | |
| headerChildren.push(new Paragraph({ | |
| style: 'header', border: { | |
| top: { style: BorderStyle.SINGLE }, | |
| bottom: { style: BorderStyle.SINGLE }, | |
| left: { style: BorderStyle.SINGLE }, | |
| right: { style: BorderStyle.SINGLE } | |
| } | |
| })); | |
| const derivedTitle = title || (content || '').split('\n').map(s => s.trim()).find(s => s.startsWith('# '))?.replace(/^#\s+/, '') || 'Report'; | |
| const bodyParagraphs: Paragraph[] = [ | |
| new Paragraph({ text: derivedTitle, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }), | |
| new Paragraph({ text: '' }), // Spacer | |
| ]; | |
| (content || '').split('\n').forEach(line => { | |
| const t = line.trim(); | |
| if (t.startsWith('### ')) { | |
| bodyParagraphs.push(new Paragraph({ text: t.replace(/^###\s+/, ''), heading: HeadingLevel.HEADING_3 })); | |
| } else if (t.startsWith('## ')) { | |
| bodyParagraphs.push(new Paragraph({ text: t.replace(/^##\s+/, ''), heading: HeadingLevel.HEADING_2 })); | |
| } else if (t.startsWith('# ') && t.replace(/^#\s+/, '') !== derivedTitle) { | |
| bodyParagraphs.push(new Paragraph({ text: t.replace(/^#\s+/, ''), heading: HeadingLevel.HEADING_1 })); | |
| } else if (/^(\* |- |• )/.test(t)) { | |
| bodyParagraphs.push(new Paragraph({ text: t.replace(/^(\* |- |• )/, ''), bullet: { level: 0 } })); | |
| } else { | |
| const runs = (line.split(/(\*\*.*?\*\*|\*.*?\*)/g).filter(Boolean)).map(part => { | |
| if (part.startsWith('**') && part.endsWith('**')) return new TextRun({ text: part.slice(2, -2), bold: true }); | |
| if (part.startsWith('*') && part.endsWith('*')) return new TextRun({ text: part.slice(1, -1), italics: true }); | |
| return new TextRun(part); | |
| }); | |
| bodyParagraphs.push(new Paragraph({ children: runs })); | |
| } | |
| }); | |
| const doc = new Document({ | |
| sections: [{ headers: { default: new DocxHeader({ children: headerChildren }) }, children: bodyParagraphs }], | |
| }); | |
| const filename = `report-${new Date().toISOString().replace(/[:.]/g, '-')}.docx`; | |
| const blob = await Packer.toBlob(doc); | |
| const link = document.createElement('a'); | |
| link.href = URL.createObjectURL(blob); | |
| link.download = filename; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(link.href); | |
| // Notify RASA that the research step is complete | |
| await sendMessage('/research_complete'); | |
| return filename; | |
| }; | |
| const sendMessage = async (outgoing: string) => { | |
| if (!outgoing.trim()) return; | |
| setIsLoading(true); | |
| try { | |
| const requestBody = { | |
| sender: senderRef.current || 'web-user', | |
| message: outgoing, | |
| metadata: { | |
| agent: selectedAgent?.id === 'lexa-legal' ? 'lexa' : 'biz' | |
| } | |
| }; | |
| const res = await fetch(`https://devyugensys-rasa-bizinsight.hf.space/webhooks/rest/webhook`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!res.ok) throw new Error(`Backend error: ${res.status} ${res.statusText}`); | |
| const data = await res.json(); | |
| prevResponseRef.current = lastResponseRef.current; | |
| lastResponseRef.current = data; | |
| const botMessages: ChatMessage[] = Array.isArray(data) | |
| ? data | |
| .map((m: any): ChatMessage | null => { | |
| let text = typeof m?.text === 'string' ? m.text : undefined; | |
| const buttons = Array.isArray(m?.buttons) | |
| ? m.buttons | |
| .filter((b: any) => typeof b?.title === 'string' && typeof b?.payload === 'string') | |
| .map((b: any) => ({ title: b.title as string, payload: b.payload as string })) | |
| : undefined; | |
| let custom: ChatMessage['custom'] = undefined; | |
| if (m?.custom && typeof m.custom === 'object' && m.custom.type === 'multi_select_chips') { | |
| const opts = Array.isArray(m.custom.options) ? m.custom.options.filter((o: any) => typeof o === 'string') : []; | |
| custom = { | |
| type: 'multi_select_chips', | |
| options: opts, | |
| columns: typeof m.custom.columns === 'number' ? m.custom.columns : undefined, | |
| hint: typeof m.custom.hint === 'string' ? m.custom.hint : undefined, | |
| selections: [], | |
| }; | |
| } | |
| let json_message = m?.json_message; | |
| if (!json_message && m?.custom && typeof m.custom === 'object' && typeof m.custom.action === 'string') { | |
| json_message = m.custom; | |
| } | |
| const action = json_message?.action; | |
| if (action === 'json:download' || action === 'pdf:download' || action === 'docx:download') { | |
| text = undefined; | |
| } | |
| if (!text && (!buttons || buttons.length === 0) && !custom && !json_message) return null; | |
| return { sender: 'bot', text, buttons, custom, json_message } as ChatMessage; | |
| }) | |
| .filter(Boolean) as ChatMessage[] | |
| : []; | |
| if (botMessages.length > 0) { | |
| const visibleMessages = botMessages.filter( | |
| (m) => !!(m.text || (m.buttons && m.buttons.length > 0) || m.custom) | |
| ); | |
| const combined = [...messages, ...visibleMessages]; | |
| setMessages(prev => [...prev, ...visibleMessages]); | |
| const pdfAction = botMessages.find(m => (m as any).json_message?.action === 'pdf:download'); | |
| const docxAction = botMessages.find(m => (m as any).json_message?.action === 'docx:download'); | |
| const jsonAction = botMessages.find(m => (m as any).json_message?.action === 'json:download'); | |
| if (pdfAction || docxAction || jsonAction) { | |
| // New, more robust logic to find the last substantive report message, | |
| // ignoring short confirmation or prompt messages. | |
| const findLastReportText = (messageList: ChatMessage[]): string => { | |
| // Search backwards from the end of the message list. | |
| for (let i = messageList.length - 1; i >= 0; i--) { | |
| const msg = messageList[i]; | |
| if (msg.sender === 'bot' && msg.text) { | |
| // Heuristic: A real report contains markdown headers ('#') or is reasonably long. | |
| // This reliably filters out short status updates like "✅ PDF downloaded...". | |
| const isLikelyReport = msg.text.includes('Report') || msg.text.length > 200; | |
| if (isLikelyReport) { | |
| return msg.text; // Found the report content | |
| } | |
| } | |
| } | |
| return ''; // No report found | |
| }; | |
| const targetText = findLastReportText(combined); | |
| if (!targetText.trim()) { | |
| pushBotNotice('❌ Could not find report content to download.'); | |
| } else { | |
| if (pdfAction) { | |
| try { | |
| const filename = await generateReportPdf({ content: targetText }); | |
| pushBotNotice(`✅ PDF downloaded: ${filename}`); | |
| } catch (e) { | |
| console.error('[PDF] Failed to generate:', e); | |
| pushBotNotice('❌ Failed to generate PDF file.'); | |
| } | |
| } | |
| if (docxAction) { | |
| try { | |
| const filename = await generateReportDocx({ content: targetText }); | |
| pushBotNotice(`✅ DOCX downloaded: ${filename}`); | |
| } catch (e) { | |
| console.error('[DOCX] Failed to generate:', e); | |
| pushBotNotice('❌ Failed to generate DOCX file.'); | |
| } | |
| } | |
| if (jsonAction) { | |
| try { | |
| // Create a proper JSON object with the report content | |
| const jsonContent = { | |
| report: targetText, | |
| generatedAt: new Date().toISOString() | |
| }; | |
| // Call downloadJson with the JSON object and let it handle the rest | |
| downloadJson(jsonContent, `report-${new Date().toISOString().slice(0, 10)}.json`); | |
| } catch (e) { | |
| console.error('[JSON] Failed to generate:', e); | |
| pushBotNotice('❌ Failed to generate JSON file.'); | |
| } | |
| } | |
| } | |
| } | |
| // if (pdfAction || docxAction) { | |
| // const botTextIdxs = combined | |
| // .map((m, idx) => ({ m, idx })) | |
| // .filter(x => x.m.sender === 'bot' && !!x.m.text) | |
| // .map(x => x.idx); | |
| // const targetIdx = | |
| // botTextIdxs.length >= 2 | |
| // ? botTextIdxs[botTextIdxs.length - 2] | |
| // : botTextIdxs[botTextIdxs.length - 1]; | |
| // const targetMsg = targetIdx !== undefined ? combined[targetIdx] : undefined; | |
| // const targetText = targetMsg?.text || ''; | |
| // if (pdfAction) { | |
| // if (!targetText.trim()) { | |
| // await startDownload(); | |
| // } else { | |
| // try { | |
| // const filename = await generateReportPdf({ content: targetText }); | |
| // pushBotNotice(`✅ PDF downloaded: ${filename}`); | |
| // } catch (e) { | |
| // console.error('[PDF] Failed to generate:', e); | |
| // } | |
| // } | |
| // } | |
| // if (docxAction) { | |
| // if (!targetText.trim()) { | |
| // pushBotNotice('❌ Could not find report content to download.'); | |
| // } else { | |
| // try { | |
| // // Use the new text-based generator | |
| // const filename = await generateReportDocx({ content: targetText }); | |
| // pushBotNotice(`✅ DOCX downloaded: ${filename}`); | |
| // } catch (e) { | |
| // console.error('[DOCX] Failed to generate:', e); | |
| // pushBotNotice('❌ Failed to generate DOCX file.'); | |
| // } | |
| // } | |
| // } | |
| // } | |
| } | |
| } catch (error) { | |
| console.error('[DEBUG] sendMessage error:', error); | |
| setMessages(prev => [...prev, { text: '❌ Something went wrong.', sender: 'bot' }]); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleSendMessage = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!inputValue.trim()) return; | |
| const userMessage = inputValue.trim(); | |
| setMessages(prev => [...prev, { text: userMessage, sender: 'user' }]); | |
| setInputValue(''); | |
| await sendMessage(userMessage); | |
| }; | |
| const handleQuickReply = async (btn: QuickReplyButton) => { | |
| const lastMultiIndex = [...messages] | |
| .map((m, idx) => ({ m, idx })) | |
| .reverse() | |
| .find(item => item.m.custom?.type === 'multi_select_chips')?.idx; | |
| const lastMulti = lastMultiIndex !== undefined ? messages[lastMultiIndex] : undefined; | |
| const selections = lastMulti?.custom?.selections ?? []; | |
| if (btn.payload.startsWith('/')) { | |
| if (selections.length > 0 && btn.payload.includes('continue')) { | |
| const payloadWithSelections = `${btn.payload}${JSON.stringify({ selections })}`; | |
| setMessages(prev => [ | |
| ...prev, | |
| { text: `${btn.title}\n${selections.join(', ')}`, sender: 'user' }, | |
| ]); | |
| await sendMessage(payloadWithSelections); | |
| return; | |
| } | |
| } | |
| setMessages(prev => [...prev, { text: btn.title, sender: 'user' }]); | |
| await sendMessage(btn.payload); | |
| }; | |
| const toggleMultiSelect = (messageIndex: number, option: string) => { | |
| setMessages(prev => { | |
| const next = [...prev]; | |
| const msg = next[messageIndex]; | |
| if (!msg?.custom || msg.custom.type !== 'multi_select_chips') return prev; | |
| const current = new Set(msg.custom.selections || []); | |
| current.has(option) ? current.delete(option) : current.add(option); | |
| msg.custom = { ...msg.custom, selections: Array.from(current) }; | |
| next[messageIndex] = { ...msg }; | |
| return next; | |
| }); | |
| }; | |
| const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| useEffect(() => { scrollToBottom(); }, [messages, isLoading]); | |
| // Preload Bizinsight welcome content on first entry | |
| useEffect(() => { | |
| if (!selectedAgent) return; | |
| const isBizInsight = selectedAgent.id === 'rival-lens' || /bizinsight/i.test(selectedAgent.name || ''); | |
| if (!isBizInsight) return; | |
| if (messages.length > 0) return; | |
| const welcomeText = "👋 Welcome to Yugensys BizResearch AI Agent!\n\nYour AI-powered business intelligence researcher. Get comprehensive insights on any company in minutes.\n\nWhat company would you like to research today?"; | |
| const buttons = [ | |
| { title: 'Hubspot', payload: '/inform_company{"company_name": "Hubspot"}' }, | |
| { title: 'Zoho', payload: '/inform_company{"company_name": "Zoho"}' }, | |
| { title: 'Salesforce', payload: '/inform_company{"company_name": "Salesforce"}' }, | |
| { title: 'Tesla', payload: '/inform_company{"company_name": "Tesla"}' }, | |
| ]; | |
| const followUpText = 'Or you can Enter company name... 🔍'; | |
| setMessages([ | |
| { sender: 'bot', text: welcomeText, buttons }, | |
| { sender: 'bot', text: followUpText }, | |
| ]); | |
| }, [selectedAgent]); | |
| if (!selectedAgent) return null; | |
| // Determine last bot message with text (rendered in ReportRenderer) | |
| const lastBotIndex = (() => { | |
| for (let i = messages.length - 1; i >= 0; i--) { | |
| const m = messages[i]; | |
| if (m.sender === 'bot' && m.text) return i; | |
| } | |
| return -1; | |
| })(); | |
| return ( | |
| <Box | |
| sx={{ | |
| position: 'fixed', | |
| right: { xs: 12, sm: 24 }, | |
| bottom: { xs: 12, sm: 24 }, | |
| width: 'clamp(320px, 36vw, 420px)', | |
| height: 'clamp(520px, 95dvh, 720px)', | |
| zIndex: 1300, | |
| pointerEvents: 'auto', | |
| bgcolor: 'transparent', | |
| }} | |
| > | |
| {/* Chat window */} | |
| <Paper | |
| elevation={0} | |
| sx={{ | |
| width: '100%', | |
| height: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| borderRadius: 10, | |
| overflow: 'hidden', | |
| boxShadow: BRAND.headerShadow, | |
| bgcolor: BRAND.windowBg, | |
| }} | |
| > | |
| {/* Header with gradient */} | |
| <Box | |
| sx={{ | |
| background: BRAND.headerBg, | |
| color: '#fff', | |
| px: 2, | |
| py: 1.5, | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 1.25, | |
| }} | |
| > | |
| <IconButton | |
| onClick={() => { | |
| if (onClose) { | |
| onClose(); | |
| } else { | |
| setSelectedAgent(null); | |
| } | |
| }} | |
| size="small" | |
| sx={{ | |
| bgcolor: 'rgba(255,255,255,0.2)', | |
| color: '#fff', | |
| '&:hover': { | |
| bgcolor: 'rgba(255,255,255,0.3)', | |
| transform: 'translateX(-2px)' | |
| }, | |
| transition: 'all 0.2s ease', | |
| width: 32, | |
| height: 32, | |
| mr: 1 | |
| }} | |
| > | |
| <ArrowBackIcon fontSize="small" /> | |
| </IconButton> | |
| <Avatar | |
| sx={{ | |
| width: 28, | |
| height: 28, | |
| border: '2px solid rgba(255,255,255,0.35)', | |
| bgcolor: 'primary.main', | |
| color: 'white' | |
| }} | |
| > | |
| <AutoAwesomeIcon | |
| sx={{ | |
| color: '#FF3D00', | |
| bgcolor: 'white', | |
| borderRadius: '50%', | |
| p: 0.8, | |
| fontSize: 31, | |
| transition: 'all 0.3s ease', | |
| '&:hover': { | |
| transform: 'scale(1.1)', | |
| }, | |
| }} | |
| /> | |
| </Avatar> | |
| <Box sx={{ flex: 1, overflow: 'hidden' }}> | |
| <Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.1 }}> | |
| {selectedAgent?.id === 'lexa-legal' ? 'Lexa' : 'BizInsights'} Assistant | |
| </Typography> | |
| <Typography variant="caption" sx={{ opacity: 0.9 }}> | |
| {selectedAgent?.id === 'lexa-legal' | |
| ? 'Legal Research Assistant' | |
| : 'Competitive Intelligence Assistant'} | |
| </Typography> | |
| </Box> | |
| </Box> | |
| {/* Conversation area */} | |
| <Box | |
| ref={chatContainerRef} | |
| sx={{ | |
| flex: 1, | |
| minHeight: 0, // Important for flexbox scrolling | |
| overflowY: 'auto', | |
| p: 1.5, | |
| overscrollBehavior: 'contain', | |
| bgcolor: BRAND.windowBg, | |
| '&::-webkit-scrollbar': { width: 8 }, | |
| '&::-webkit-scrollbar-thumb': { | |
| backgroundColor: '#E5E7EB', | |
| borderRadius: 8, | |
| }, | |
| }} | |
| > | |
| <List sx={{ width: '100%', py: 0 }}> | |
| {messages.map((msg, i) => ( | |
| <ListItem | |
| key={i} | |
| sx={{ | |
| justifyContent: msg.sender === 'user' ? 'flex-end' : 'flex-start', | |
| alignItems: 'flex-start', | |
| px: 0.5, | |
| }} | |
| > | |
| {msg.sender === 'bot' && ( | |
| <ListItemAvatar sx={{ minWidth: 38, mt: 0.5 }}> | |
| <Avatar | |
| sx={{ | |
| width: 28, | |
| height: 28, | |
| border: `2px solid ${BRAND.border}`, | |
| bgcolor: 'primary.main', | |
| color: 'white' | |
| }} | |
| > | |
| <AutoAwesomeIcon | |
| sx={{ | |
| color: '#FF3D00', | |
| bgcolor: 'white', | |
| borderRadius: '50%', | |
| p: 0.8, | |
| fontSize: 28, | |
| transition: 'all 0.3s ease', | |
| '&:hover': { | |
| transform: 'scale(1.1)', | |
| }, | |
| }} | |
| /> | |
| </Avatar> | |
| </ListItemAvatar> | |
| )} | |
| <Paper | |
| sx={{ | |
| p: 1.25, | |
| px: 1.5, | |
| bgcolor: msg.sender === 'user' ? BRAND.userBubble : BRAND.botBubble, | |
| borderRadius: 3, | |
| boxShadow: '0 4px 14px rgba(0,0,0,0.06)', | |
| maxWidth: '85%', | |
| position: 'relative', | |
| ...(msg.sender === 'user' && { borderTopRightRadius: 4, color: BRAND.userText }), | |
| ...(msg.sender === 'bot' && { borderTopLeftRadius: 4 }), | |
| }} | |
| > | |
| {msg.sender === 'bot' && msg.text ? ( | |
| <> | |
| <Box sx={{ | |
| '& > *:not(:last-child)': { mb: 1.5 }, | |
| '& h1, & h2, & h3, & h4, & h5, & h6': { | |
| color: 'primary.main', | |
| mt: 2, | |
| mb: 1.5 | |
| }, | |
| '& pre': { | |
| backgroundColor: 'rgba(0,0,0,0.05)', | |
| p: 2, | |
| borderRadius: 1, | |
| overflowX: 'auto' | |
| }, | |
| '& table': { | |
| borderCollapse: 'collapse', | |
| width: '100%', | |
| mb: 2, | |
| '& th, & td': { | |
| border: '1px solid', | |
| borderColor: 'divider', | |
| p: 1, | |
| textAlign: 'left' | |
| }, | |
| '& th': { | |
| backgroundColor: 'action.hover', | |
| fontWeight: 'bold' | |
| }, | |
| }, | |
| }}> | |
| <ReportRenderer content={msg.text} /> | |
| </Box> | |
| {msg.buttons && msg.buttons.length > 0 && ( | |
| <Box sx={{ mt: 1.5, display: 'flex', flexWrap: 'wrap', gap: 1 }}> | |
| {msg.buttons.map((btn, idx) => ( | |
| <Chip | |
| key={idx} | |
| label={btn.title} | |
| onClick={() => handleQuickReply(btn)} | |
| sx={{ | |
| bgcolor: BRAND.chipBg, | |
| color: BRAND.chipText, | |
| '&:hover': { bgcolor: '#FFD4BB' }, | |
| cursor: 'pointer', | |
| }} | |
| /> | |
| ))} | |
| </Box> | |
| )} | |
| </> | |
| ) : ( | |
| <> | |
| {msg.text && ( | |
| <Typography | |
| variant="body2" | |
| sx={{ whiteSpace: 'pre-line', lineHeight: 1.5 }} | |
| dangerouslySetInnerHTML={{ __html: msg.text.replace(/\n/g, '<br />') }} | |
| /> | |
| )} | |
| {msg.buttons && msg.buttons.length > 0 && ( | |
| <Box sx={{ mt: 1.5, display: 'flex', flexWrap: 'wrap', gap: 1 }}> | |
| {msg.buttons.map((btn, idx) => ( | |
| <Chip | |
| key={idx} | |
| label={btn.title} | |
| onClick={() => handleQuickReply(btn)} | |
| sx={{ | |
| bgcolor: BRAND.chipBg, | |
| color: BRAND.chipText, | |
| '&:hover': { bgcolor: '#FFD4BB' }, | |
| cursor: 'pointer', | |
| }} | |
| /> | |
| ))} | |
| </Box> | |
| )} | |
| </> | |
| )} | |
| {/* Multi-select chips (custom payload) */} | |
| {msg.custom?.type === 'multi_select_chips' && ( | |
| <Box sx={{ mt: 1.5 }}> | |
| {msg.custom.hint && ( | |
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | |
| {msg.custom.hint} | |
| </Typography> | |
| )} | |
| <Box | |
| sx={{ | |
| display: 'grid', | |
| gridTemplateColumns: `repeat(${msg.custom?.columns || 2}, minmax(0, 1fr))`, | |
| gap: 0.75, | |
| }} | |
| > | |
| {(msg.custom.options || []).map((opt) => { | |
| const selected = !!msg.custom?.selections?.includes(opt); | |
| return ( | |
| <Chip | |
| key={opt} | |
| label={opt} | |
| onClick={() => toggleMultiSelect(i, opt)} | |
| variant={selected ? 'filled' : 'outlined'} | |
| color={selected ? 'primary' : undefined} | |
| sx={{ | |
| borderRadius: 999, | |
| width: '100%', | |
| justifyContent: 'flex-start', | |
| bgcolor: selected ? BRAND.chipBg : 'transparent', | |
| borderColor: BRAND.inputBorder, | |
| color: BRAND.chipText, | |
| '& .MuiChip-label': { whiteSpace: 'normal' }, | |
| '&:hover': { bgcolor: selected ? '#FFD4BB' : 'transparent' }, | |
| }} | |
| /> | |
| ); | |
| })} | |
| </Box> | |
| </Box> | |
| )} | |
| </Paper> | |
| </ListItem> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </List> | |
| </Box> | |
| {/* Composer */} | |
| <Box | |
| component="form" | |
| onSubmit={handleSendMessage} | |
| sx={{ | |
| p: 1.25, | |
| bgcolor: BRAND.windowBg, | |
| borderTop: '1px solid #EDF0F5', | |
| flexShrink: 0, | |
| }} | |
| > | |
| <TextField | |
| fullWidth | |
| variant="outlined" | |
| placeholder="Type here" | |
| value={inputValue} | |
| onChange={(e) => setInputValue(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSendMessage(e); | |
| } | |
| }} | |
| multiline | |
| maxRows={4} | |
| disabled={isLoading} | |
| InputProps={{ | |
| endAdornment: ( | |
| <InputAdornment position="end" sx={{ mr: 0.25 }}> | |
| <IconButton | |
| type="submit" | |
| disabled={!inputValue.trim() || isLoading} | |
| sx={{ | |
| bgcolor: BRAND.sendBtn, | |
| color: '#fff', | |
| width: 40, | |
| height: 40, | |
| borderRadius: 2, | |
| boxShadow: '0 6px 18px rgba(250, 140, 106, 0.35)', | |
| '&:hover': { bgcolor: BRAND.sendBtnHover }, | |
| }} | |
| edge="end" | |
| > | |
| <SendIcon fontSize="small" /> | |
| </IconButton> | |
| </InputAdornment> | |
| ), | |
| sx: { | |
| borderRadius: 3, | |
| bgcolor: BRAND.inputBg, | |
| '& .MuiOutlinedInput-input::placeholder': { | |
| color: BRAND.placeholder, | |
| opacity: 1, | |
| }, | |
| '& .MuiOutlinedInput-notchedOutline': { | |
| borderColor: BRAND.inputBorder, | |
| }, | |
| '&:hover .MuiOutlinedInput-notchedOutline': { | |
| borderColor: BRAND.inputBorder, | |
| }, | |
| '&.Mui-focused .MuiOutlinedInput-notchedOutline': { | |
| borderColor: BRAND.sendBtn, | |
| borderWidth: 1.5, | |
| }, | |
| }, | |
| }} | |
| /> | |
| </Box> | |
| </Paper> | |
| </Box> | |
| ); | |
| }; | |
| export default ChatInterface; |