pranav8tripathi@gmail.com
init
3937b35
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;