pranav8tripathi@gmail.com commited on
Commit
44cb147
·
1 Parent(s): 3c68544

Logos fixed

Browse files
src/components/ChatInterface.tsx CHANGED
@@ -36,6 +36,7 @@ const ChatInterface: React.FC = () => {
36
  // Helper to push a bot notice message after an operation completes
37
  const pushBotNotice = (text: string) =>
38
  setMessages(prev => [...prev, { sender: 'bot', text }]);
 
39
  type ChatMessage = {
40
  sender: 'user' | 'bot';
41
  text?: string;
@@ -57,7 +58,11 @@ const ChatInterface: React.FC = () => {
57
  const lastResponseRef = useRef<any>(null);
58
  const prevResponseRef = useRef<any>(null);
59
 
60
- // Utility: download an object as a .json file
 
 
 
 
61
  const downloadJson = (obj: any, filename?: string) => {
62
  try {
63
  const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
@@ -74,93 +79,294 @@ const ChatInterface: React.FC = () => {
74
  }
75
  };
76
 
77
- const captureNodeToPdf = async (
78
- node: HTMLElement,
79
- { format = 'pdf', onDone }: { format?: 'pdf'; onDone?: (filename: string) => void }
80
- ) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  try {
82
- if (!node) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- // Temporarily expand node for high-quality capture
85
- const prev = {
86
- width: node.style.width,
87
- maxWidth: (node.style as any).maxWidth,
88
- background: node.style.background,
89
- padding: node.style.padding,
90
- boxShadow: node.style.boxShadow,
91
- };
92
- node.style.background = '#ffffff';
93
- node.style.padding = '16px';
94
- (node.style as any).maxWidth = 'unset';
95
- node.style.width = '1024px'; // target layout width for better readability
96
- node.style.boxShadow = 'none';
97
 
98
- // Ensure white background for canvas
99
- const canvas = await html2canvas(node, {
100
- scale: 3, // higher scale for sharper text
101
- useCORS: true,
102
- backgroundColor: '#ffffff',
103
- windowWidth: node.scrollWidth,
104
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- const imgData = canvas.toDataURL('image/png');
107
- const pdf = new jsPDF('p', 'mm', 'a4');
108
- const pageWidth = pdf.internal.pageSize.getWidth();
109
- const pageHeight = pdf.internal.pageSize.getHeight();
110
-
111
- // Scale to full page width with margins and paginate vertically
112
- const margin = 10; // mm
113
- const renderableWidth = pageWidth - margin * 2;
114
- const ratio = renderableWidth / canvas.width;
115
- const imgWidth = renderableWidth;
116
- const imgHeight = canvas.height * ratio;
117
-
118
- let heightLeft = imgHeight;
119
- let position = margin; // y position in mm
120
-
121
- // First page
122
- pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
123
- heightLeft -= (pageHeight - margin * 2);
124
-
125
- // Additional pages
126
- while (heightLeft > 0) {
127
- pdf.addPage();
128
- // shift the image up by the amount already printed
129
- position = margin - (imgHeight - heightLeft);
130
- pdf.addImage(imgData, 'PNG', margin, position, imgWidth, imgHeight);
131
- heightLeft -= (pageHeight - margin * 2);
132
  }
133
 
134
- const filename = `report-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.pdf`;
135
- pdf.save(filename);
136
- onDone?.(filename);
137
- // Restore styles
138
- node.style.width = prev.width;
139
- (node.style as any).maxWidth = prev.maxWidth;
140
- node.style.background = prev.background;
141
- node.style.padding = prev.padding;
142
- node.style.boxShadow = prev.boxShadow;
143
- } catch (err) {
144
- console.error('[PDF] Failed to create PDF:', err);
145
  }
146
- };
 
147
 
148
- const startDownload = async ({ format = 'pdf' }: { format?: 'pdf' }) => {
149
- // Default to lastReportRef for backward compatibility
150
- const node = lastReportRef.current;
151
- if (!node) {
152
- console.warn('[PDF] No report node found to capture');
153
- return;
154
- }
155
- await captureNodeToPdf(node, {
156
- format,
157
- onDone: (fn) => {
158
- // Show success bubble, do not send anything to backend
159
- pushBotNotice(`✅ PDF downloaded: ${fn}`);
160
- },
161
- });
162
  };
163
 
 
164
  const captureNodeToDocx = async (node: HTMLElement, onDone?: (filename: string) => void) => {
165
  if (!node) return;
166
  try {
@@ -198,10 +404,7 @@ const ChatInterface: React.FC = () => {
198
  const targetWidthPx = Math.floor(contentWidthInches * dpi);
199
  const pageSliceHeightPx = Math.floor(contentHeightInches * dpi);
200
 
201
- // Accumulate children first; construct Document after loop to avoid empty docs
202
  const sectionChildren: Paragraph[] = [];
203
-
204
- // Slice the tall canvas into page-height chunks and add each as an image
205
  const totalHeightPx = canvas.height;
206
  let offsetY = 0;
207
 
@@ -212,7 +415,7 @@ const ChatInterface: React.FC = () => {
212
  tempCanvas.height = sliceHeightPx;
213
  const tctx = tempCanvas.getContext('2d');
214
  if (!tctx) break;
215
- // Draw the corresponding slice
216
  tctx.drawImage(
217
  canvas,
218
  0,
@@ -225,7 +428,6 @@ const ChatInterface: React.FC = () => {
225
  tempCanvas.height
226
  );
227
 
228
- // Create image buffer from dataURL
229
  const dataUrl = tempCanvas.toDataURL('image/png');
230
  const res = await fetch(dataUrl);
231
  const blob = await res.blob();
@@ -233,8 +435,7 @@ const ChatInterface: React.FC = () => {
233
 
234
  const ratio = targetWidthPx / canvas.width;
235
  const targetHeightPx = Math.round(sliceHeightPx * ratio);
236
- // Create an ImageRun directly (Media.addImage is not available in current docx version)
237
- // Cast to any to satisfy docx@9.5.1 union typings (raster image with PNG data)
238
  const imageRun: ImageRun = new ImageRun({
239
  data: new Uint8Array(arrayBuffer),
240
  transformation: { width: targetWidthPx, height: targetHeightPx },
@@ -243,7 +444,6 @@ const ChatInterface: React.FC = () => {
243
  offsetY += sliceHeightPx;
244
  }
245
 
246
- // Create the document now with populated children
247
  const doc = new Document({
248
  sections: [
249
  {
@@ -273,7 +473,6 @@ const ChatInterface: React.FC = () => {
273
  link.remove();
274
  onDone?.(filename);
275
 
276
- // Restore styles
277
  node.style.width = prev.width;
278
  (node.style as any).maxWidth = prev.maxWidth;
279
  node.style.background = prev.background;
@@ -284,6 +483,31 @@ const ChatInterface: React.FC = () => {
284
  }
285
  };
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  const sendMessage = async (outgoing: string) => {
288
  if (!outgoing.trim()) return;
289
  setIsLoading(true);
@@ -353,6 +577,7 @@ const ChatInterface: React.FC = () => {
353
  const pdfAction = botMessages.find(m => (m as any).json_message?.action === 'pdf:download');
354
  const docxAction = botMessages.find(m => (m as any).json_message?.action === 'docx:download');
355
  const jsonAction = botMessages.find(m => (m as any).json_message?.action === 'json:download');
 
356
  if (jsonAction) {
357
  const toDownload = prevResponseRef.current ?? lastResponseRef.current ?? data;
358
  console.log('[JSON] Detected json:download action. Downloading PRIOR REST response.', {
@@ -361,43 +586,50 @@ const ChatInterface: React.FC = () => {
361
  downloadJson(toDownload);
362
  pushBotNotice('✅ JSON downloaded.');
363
  }
 
364
  if (pdfAction || docxAction) {
365
- // Determine the last-to-last bot text message index in the combined list
366
  const botTextIdxs = combined
367
  .map((m, idx) => ({ m, idx }))
368
  .filter(x => x.m.sender === 'bot' && !!x.m.text)
369
  .map(x => x.idx);
370
- const targetIdx = botTextIdxs.length >= 2 ? botTextIdxs[botTextIdxs.length - 2] : botTextIdxs[botTextIdxs.length - 1];
371
-
372
- // Wait for DOM to update, then capture the targeted node
373
- setTimeout(() => {
374
- if (pdfAction) console.log('[PDF] Detected pdf:download action. Target index:', targetIdx);
375
- if (docxAction) console.log('[DOCX] Detected docx:download action. Target index:', targetIdx);
376
- const node = reportRefs.current.get(targetIdx);
377
- if (node) {
378
- if (pdfAction) {
379
- const fmt = (pdfAction as any).json_message?.format || 'pdf';
380
- captureNodeToPdf(node, {
381
- format: fmt,
382
- onDone: (fn) => {
383
- // Show success bubble only
384
- pushBotNotice(`✅ PDF downloaded: ${fn}`);
385
- },
386
- });
387
- } else if (docxAction) {
388
- captureNodeToDocx(node, (fn) => pushBotNotice(`✅ DOCX downloaded: ${fn}`));
389
- }
390
  } else {
391
- console.warn('[EXPORT] Target report node not found; falling back to lastReportRef');
392
- if (pdfAction) {
393
- const fmt = (pdfAction as any).json_message?.format || 'pdf';
394
- startDownload({ format: fmt });
395
- } else if (docxAction) {
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  const fallback = lastReportRef.current;
397
  if (fallback) captureNodeToDocx(fallback, (fn) => pushBotNotice(`✅ DOCX downloaded: ${fn}`));
398
  }
399
- }
400
- }, 500);
401
  }
402
  }
403
  } catch (error) {
@@ -463,6 +695,7 @@ const ChatInterface: React.FC = () => {
463
  return next;
464
  });
465
  };
 
466
  const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
467
  useEffect(() => { scrollToBottom(); }, [messages, isLoading]);
468
 
@@ -552,7 +785,6 @@ const ChatInterface: React.FC = () => {
552
  display: 'flex',
553
  flexWrap: 'wrap',
554
  gap: 1,
555
- // Each chip takes equal width per column if columns provided
556
  '& > .multi-chip': {
557
  flex: msg.custom.columns ? `0 0 ${100 / msg.custom.columns}%` : '0 0 auto',
558
  },
@@ -638,4 +870,4 @@ const ChatInterface: React.FC = () => {
638
  );
639
  };
640
 
641
- export default ChatInterface;
 
36
  // Helper to push a bot notice message after an operation completes
37
  const pushBotNotice = (text: string) =>
38
  setMessages(prev => [...prev, { sender: 'bot', text }]);
39
+
40
  type ChatMessage = {
41
  sender: 'user' | 'bot';
42
  text?: string;
 
58
  const lastResponseRef = useRef<any>(null);
59
  const prevResponseRef = useRef<any>(null);
60
 
61
+ // Static public paths for logos (put files in /public)
62
+ const YUGEN_LOGO_SRC = '/yugensys.png';
63
+ const BIZ_LOGO_SRC = '/BizInsaights.png'; // NOTE: fixed spelling
64
+
65
+ // Util: download an object as a .json file
66
  const downloadJson = (obj: any, filename?: string) => {
67
  try {
68
  const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
 
79
  }
80
  };
81
 
82
+ // ---- PDF: helpers (fetch + downscale) ----
83
+ const fetchAsDataURL = async (src: string): Promise<string> => {
84
+ const res = await fetch(src, { credentials: 'include', cache: 'force-cache' });
85
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${src}`);
86
+ const blob = await res.blob();
87
+ return await new Promise<string>((resolve) => {
88
+ const reader = new FileReader();
89
+ reader.onloadend = () => resolve(reader.result as string);
90
+ reader.readAsDataURL(blob);
91
+ });
92
+ };
93
+
94
+ const downscaleDataURL = async (
95
+ dataUrl: string,
96
+ maxEdgePx: number,
97
+ mime: 'image/jpeg' | 'image/png',
98
+ quality: number
99
+ ): Promise<string> => {
100
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
101
+ const i = new Image();
102
+ i.onload = () => resolve(i);
103
+ i.onerror = () => reject(new Error('Image decode failed'));
104
+ i.src = dataUrl;
105
+ });
106
+
107
+ const naturalW = (img as any).naturalWidth || img.width;
108
+ const naturalH = (img as any).naturalHeight || img.height;
109
+ const longest = Math.max(naturalW, naturalH);
110
+ const scale = longest > maxEdgePx ? maxEdgePx / longest : 1;
111
+ const w = Math.max(1, Math.round(naturalW * scale));
112
+ const h = Math.max(1, Math.round(naturalH * scale));
113
+
114
+ const canvas = document.createElement('canvas');
115
+ canvas.width = w;
116
+ canvas.height = h;
117
+ const ctx = canvas.getContext('2d');
118
+ if (!ctx) throw new Error('Canvas 2D context unavailable');
119
+
120
+ if (mime === 'image/jpeg') {
121
+ ctx.fillStyle = '#ffffff';
122
+ ctx.fillRect(0, 0, w, h);
123
+ }
124
+ ctx.drawImage(img, 0, 0, w, h);
125
+ return canvas.toDataURL(mime, quality);
126
+ };
127
+
128
+ // ---- PDF: renderer with logos + selectable text ----
129
+ const generateReportPdf = async ({
130
+ content,
131
+ title,
132
+ yugenLogoSrc = YUGEN_LOGO_SRC,
133
+ bizLogoSrc = BIZ_LOGO_SRC,
134
+ logoMime = 'image/jpeg' as 'image/jpeg' | 'image/png',
135
+ maxLogoEdgePx = 600,
136
+ jpegQuality = 0.82,
137
+ }: {
138
+ content: string;
139
+ title?: string;
140
+ yugenLogoSrc?: string;
141
+ bizLogoSrc?: string;
142
+ logoMime?: 'image/jpeg' | 'image/png';
143
+ maxLogoEdgePx?: number;
144
+ jpegQuality?: number;
145
+ }): Promise<string> => {
146
+ console.info('[PDF] Generating...', { yugenLogoSrc, bizLogoSrc, logoMime });
147
+
148
+ const doc = new jsPDF({ unit: 'mm', format: 'a4', compress: true });
149
+ const pageWidth = doc.internal.pageSize.getWidth();
150
+ const pageHeight = doc.internal.pageSize.getHeight();
151
+ const margin = 15;
152
+ const logoHeight = 15; // mm
153
+ const logoWidth = 40; // mm
154
+ const contentWidth = pageWidth - 2 * margin;
155
+
156
+ // Title: derive from first markdown header if not provided
157
+ const derivedTitle = (() => {
158
+ const firstHeader = (content || '')
159
+ .split('\n')
160
+ .map(s => s.trim())
161
+ .find(s => s.startsWith('# '));
162
+ return title || (firstHeader ? firstHeader.replace(/^#\s+/, '') : 'Report');
163
+ })();
164
+
165
+ // Load + downscale logos
166
+ let yugenDataUrl: string | null = null;
167
+ let bizDataUrl: string | null = null;
168
  try {
169
+ // Add a timestamp to prevent caching issues
170
+ const yugenWithCacheBust = `${yugenLogoSrc}?t=${Date.now()}`;
171
+ const yugenRaw = await fetchAsDataURL(yugenWithCacheBust);
172
+ yugenDataUrl = await downscaleDataURL(yugenRaw, maxLogoEdgePx, 'image/png', 1.0);
173
+ } catch (e) {
174
+ console.warn('[PDF] Yugen logo load failed, will skip:', yugenLogoSrc, e);
175
+ }
176
+
177
+ try {
178
+ // Add a timestamp to prevent caching issues
179
+ const bizWithCacheBust = `${bizLogoSrc}?t=${Date.now()}`;
180
+ const bizRaw = await fetchAsDataURL(bizWithCacheBust);
181
+ bizDataUrl = await downscaleDataURL(bizRaw, maxLogoEdgePx, 'image/png', 1.0);
182
+ } catch (e) {
183
+ console.warn('[PDF] Biz logo load failed, will skip:', bizLogoSrc, e);
184
+ }
185
+ console.log('Base URL:', window.location.origin);
186
+ console.log('Trying to load logos from:', {
187
+ yugen: yugenLogoSrc,
188
+ biz: bizLogoSrc,
189
+ fullPaths: {
190
+ yugen: window.location.origin + yugenLogoSrc,
191
+ biz: window.location.origin + bizLogoSrc
192
+ }
193
+ });
194
+ const YUGEN_ALIAS = 'YUGEN_LOGO';
195
+ const BIZ_ALIAS = 'BIZ_LOGO';
196
+ let embeddedOnce = false;
197
+
198
+ const addHeader = () => {
199
+ const fmt = logoMime === 'image/png' ? 'PNG' : 'JPEG';
200
+ if (yugenDataUrl) {
201
+ if (!embeddedOnce) {
202
+ doc.addImage(yugenDataUrl, fmt, margin, margin, logoWidth, logoHeight, YUGEN_ALIAS);
203
+ } else {
204
+ doc.addImage(YUGEN_ALIAS, fmt, margin, margin, logoWidth, logoHeight);
205
+ }
206
+ }
207
+ if (bizDataUrl) {
208
+ const x = pageWidth - margin - logoWidth;
209
+ if (!embeddedOnce) {
210
+ doc.addImage(bizDataUrl, fmt, x, margin, logoWidth, logoHeight, BIZ_ALIAS);
211
+ } else {
212
+ doc.addImage(BIZ_ALIAS, fmt, x, margin, logoWidth, logoHeight);
213
+ }
214
+ }
215
+ embeddedOnce = true;
216
+ doc.setDrawColor(200);
217
+ doc.setLineWidth(0.3);
218
+ doc.line(margin, margin + logoHeight + 4, pageWidth - margin, margin + logoHeight + 4);
219
+ };
220
+
221
+ const ensurePage = (nextBlockHeight = 0) => {
222
+ if (cursorY + nextBlockHeight > pageHeight - margin) {
223
+ doc.addPage();
224
+ addHeader();
225
+ cursorY = margin + logoHeight + 10;
226
+ }
227
+ };
228
+
229
+ // First page header + title
230
+ addHeader();
231
+ doc.setFont('helvetica', 'bold');
232
+ doc.setFontSize(18);
233
+ doc.text(derivedTitle, pageWidth / 2, margin + logoHeight + 14, { align: 'center' });
234
+ doc.setFont('helvetica', 'normal');
235
+ doc.setFontSize(12);
236
+
237
+ let cursorY = margin + logoHeight + 24;
238
+ const baseLine = 6; // mm baseline leading
239
+
240
+ // Very light markdown handling: H1/H2/H3, bullets, code blocks, blank lines
241
+ const lines = (content || '').split('\n');
242
+ let inCode = false;
243
+ const codeLines: string[] = [];
244
+
245
+ const flushCode = () => {
246
+ if (!inCode || codeLines.length === 0) return;
247
+ doc.setFont('courier', 'normal');
248
+ const lh = 5;
249
+ for (const cl of codeLines) {
250
+ const wrapped = doc.splitTextToSize(cl, contentWidth);
251
+ for (const w of wrapped) {
252
+ ensurePage(lh);
253
+ doc.text(w, margin, cursorY);
254
+ cursorY += lh;
255
+ }
256
+ }
257
+ doc.setFont('helvetica', 'normal');
258
+ inCode = false;
259
+ codeLines.length = 0;
260
+ cursorY += 1.5;
261
+ };
262
+
263
+ for (let raw of lines) {
264
+ const line = raw ?? '';
265
+ const t = line.trim();
266
+
267
+ // Code fences
268
+ if (t.startsWith('```')) {
269
+ if (inCode) {
270
+ flushCode();
271
+ } else {
272
+ inCode = true;
273
+ codeLines.length = 0;
274
+ }
275
+ continue;
276
+ }
277
+ if (inCode) {
278
+ codeLines.push(line);
279
+ continue;
280
+ }
281
 
282
+ // Blank line
283
+ if (!t) {
284
+ cursorY += baseLine;
285
+ continue;
286
+ }
 
 
 
 
 
 
 
 
287
 
288
+ // Headers
289
+ if (t.startsWith('# ')) {
290
+ const txt = t.replace(/^#\s+/, '');
291
+ doc.setFont('helvetica', 'bold');
292
+ doc.setFontSize(16);
293
+ const wrapped = doc.splitTextToSize(txt, contentWidth);
294
+ ensurePage(baseLine * wrapped.length + 4);
295
+ for (const w of wrapped) {
296
+ doc.text(w, margin, cursorY);
297
+ cursorY += baseLine;
298
+ }
299
+ cursorY += 2;
300
+ doc.setFont('helvetica', 'normal');
301
+ doc.setFontSize(12);
302
+ continue;
303
+ }
304
+ if (t.startsWith('## ')) {
305
+ const txt = t.replace(/^##\s+/, '');
306
+ doc.setFont('helvetica', 'bold');
307
+ doc.setFontSize(14);
308
+ const wrapped = doc.splitTextToSize(txt, contentWidth);
309
+ ensurePage(baseLine * wrapped.length + 2);
310
+ for (const w of wrapped) {
311
+ doc.text(w, margin, cursorY);
312
+ cursorY += baseLine;
313
+ }
314
+ cursorY += 1;
315
+ doc.setFont('helvetica', 'normal');
316
+ doc.setFontSize(12);
317
+ continue;
318
+ }
319
+ if (t.startsWith('### ')) {
320
+ const txt = t.replace(/^###\s+/, '');
321
+ doc.setFont('helvetica', 'bold');
322
+ doc.setFontSize(12);
323
+ const wrapped = doc.splitTextToSize(txt, contentWidth);
324
+ ensurePage(baseLine * wrapped.length + 1);
325
+ for (const w of wrapped) {
326
+ doc.text(w, margin, cursorY);
327
+ cursorY += baseLine;
328
+ }
329
+ doc.setFont('helvetica', 'normal');
330
+ continue;
331
+ }
332
 
333
+ // Bullets
334
+ if (/^(\* |- |• )/.test(t)) {
335
+ const bulletText = t.replace(/^(\* |- |• )/, '');
336
+ const bulletIndent = 6;
337
+ const textX = margin + bulletIndent;
338
+ const wrapWidth = contentWidth - bulletIndent;
339
+ const wrapped = doc.splitTextToSize(bulletText, wrapWidth);
340
+ ensurePage(baseLine * wrapped.length);
341
+ doc.text('•', margin + 1, cursorY);
342
+ doc.text(wrapped[0], textX, cursorY);
343
+ cursorY += baseLine;
344
+ for (let k = 1; k < wrapped.length; k++) {
345
+ ensurePage(baseLine);
346
+ doc.text(wrapped[k], textX, cursorY);
347
+ cursorY += baseLine;
348
+ }
349
+ continue;
 
 
 
 
 
 
 
 
 
350
  }
351
 
352
+ // Regular paragraph
353
+ const wrapped = doc.splitTextToSize(line, contentWidth);
354
+ ensurePage(baseLine * wrapped.length);
355
+ for (const w of wrapped) {
356
+ doc.text(w, margin, cursorY);
357
+ cursorY += baseLine;
358
+ }
 
 
 
 
359
  }
360
+ // If ended inside code block, flush
361
+ if (inCode) flushCode();
362
 
363
+ const filename = `report-${new Date().toISOString().replace(/[:.]/g, '-')}.pdf`;
364
+ console.info('[PDF] Saving file:', filename);
365
+ doc.save(filename);
366
+ return filename;
 
 
 
 
 
 
 
 
 
 
367
  };
368
 
369
+ // DOCX export stays raster (image slices)
370
  const captureNodeToDocx = async (node: HTMLElement, onDone?: (filename: string) => void) => {
371
  if (!node) return;
372
  try {
 
404
  const targetWidthPx = Math.floor(contentWidthInches * dpi);
405
  const pageSliceHeightPx = Math.floor(contentHeightInches * dpi);
406
 
 
407
  const sectionChildren: Paragraph[] = [];
 
 
408
  const totalHeightPx = canvas.height;
409
  let offsetY = 0;
410
 
 
415
  tempCanvas.height = sliceHeightPx;
416
  const tctx = tempCanvas.getContext('2d');
417
  if (!tctx) break;
418
+
419
  tctx.drawImage(
420
  canvas,
421
  0,
 
428
  tempCanvas.height
429
  );
430
 
 
431
  const dataUrl = tempCanvas.toDataURL('image/png');
432
  const res = await fetch(dataUrl);
433
  const blob = await res.blob();
 
435
 
436
  const ratio = targetWidthPx / canvas.width;
437
  const targetHeightPx = Math.round(sliceHeightPx * ratio);
438
+
 
439
  const imageRun: ImageRun = new ImageRun({
440
  data: new Uint8Array(arrayBuffer),
441
  transformation: { width: targetWidthPx, height: targetHeightPx },
 
444
  offsetY += sliceHeightPx;
445
  }
446
 
 
447
  const doc = new Document({
448
  sections: [
449
  {
 
473
  link.remove();
474
  onDone?.(filename);
475
 
 
476
  node.style.width = prev.width;
477
  (node.style as any).maxWidth = prev.maxWidth;
478
  node.style.background = prev.background;
 
483
  }
484
  };
485
 
486
+ // Fallback manual PDF download (uses text PDF generator)
487
+ const startDownload = async () => {
488
+ // pick the latest bot text
489
+ const idx = (() => {
490
+ for (let i = messages.length - 1; i >= 0; i--) {
491
+ if (messages[i].sender === 'bot' && messages[i].text) return i;
492
+ }
493
+ return -1;
494
+ })();
495
+ const text = idx >= 0 ? (messages[idx].text || '') : '';
496
+ if (!text.trim()) {
497
+ console.warn('[PDF] No bot text content available to export.');
498
+ return;
499
+ }
500
+ try {
501
+ const filename = await generateReportPdf({
502
+ content: text,
503
+ bizLogoSrc: BIZ_LOGO_SRC // Use the constant for consistency
504
+ });
505
+ pushBotNotice(`✅ PDF downloaded: ${filename}`);
506
+ } catch (e) {
507
+ console.error('[PDF] Failed to generate:', e);
508
+ }
509
+ };
510
+
511
  const sendMessage = async (outgoing: string) => {
512
  if (!outgoing.trim()) return;
513
  setIsLoading(true);
 
577
  const pdfAction = botMessages.find(m => (m as any).json_message?.action === 'pdf:download');
578
  const docxAction = botMessages.find(m => (m as any).json_message?.action === 'docx:download');
579
  const jsonAction = botMessages.find(m => (m as any).json_message?.action === 'json:download');
580
+
581
  if (jsonAction) {
582
  const toDownload = prevResponseRef.current ?? lastResponseRef.current ?? data;
583
  console.log('[JSON] Detected json:download action. Downloading PRIOR REST response.', {
 
586
  downloadJson(toDownload);
587
  pushBotNotice('✅ JSON downloaded.');
588
  }
589
+
590
  if (pdfAction || docxAction) {
591
+ // Find the last-to-last bot text message index in the combined list
592
  const botTextIdxs = combined
593
  .map((m, idx) => ({ m, idx }))
594
  .filter(x => x.m.sender === 'bot' && !!x.m.text)
595
  .map(x => x.idx);
596
+ const targetIdx =
597
+ botTextIdxs.length >= 2
598
+ ? botTextIdxs[botTextIdxs.length - 2]
599
+ : botTextIdxs[botTextIdxs.length - 1];
600
+
601
+ const targetMsg = targetIdx !== undefined ? combined[targetIdx] : undefined;
602
+ const targetText = targetMsg?.text || '';
603
+
604
+ if (pdfAction) {
605
+ console.log('[PDF] Detected pdf:download action. Target index:', targetIdx, 'chars:', targetText.length);
606
+ if (!targetText.trim()) {
607
+ console.warn('[PDF] No target text found; falling back to latest bot text.');
608
+ await startDownload();
 
 
 
 
 
 
 
609
  } else {
610
+ try {
611
+ const filename = await generateReportPdf({ content: targetText });
612
+ pushBotNotice(`✅ PDF downloaded: ${filename}`);
613
+ } catch (e) {
614
+ console.error('[PDF] Failed to generate:', e);
615
+ }
616
+ }
617
+ }
618
+
619
+ if (docxAction) {
620
+ console.log('[DOCX] Detected docx:download action. Target index:', targetIdx);
621
+ // For DOCX we still rely on DOM capture (since it’s raster anyway)
622
+ setTimeout(() => {
623
+ const node = reportRefs.current.get(targetIdx as number);
624
+ if (node) {
625
+ captureNodeToDocx(node, (fn) => pushBotNotice(`✅ DOCX downloaded: ${fn}`));
626
+ } else {
627
+ console.warn('[DOCX] Target report node not found; falling back to lastReportRef');
628
  const fallback = lastReportRef.current;
629
  if (fallback) captureNodeToDocx(fallback, (fn) => pushBotNotice(`✅ DOCX downloaded: ${fn}`));
630
  }
631
+ }, 300);
632
+ }
633
  }
634
  }
635
  } catch (error) {
 
695
  return next;
696
  });
697
  };
698
+
699
  const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
700
  useEffect(() => { scrollToBottom(); }, [messages, isLoading]);
701
 
 
785
  display: 'flex',
786
  flexWrap: 'wrap',
787
  gap: 1,
 
788
  '& > .multi-chip': {
789
  flex: msg.custom.columns ? `0 0 ${100 / msg.custom.columns}%` : '0 0 auto',
790
  },
 
870
  );
871
  };
872
 
873
+ export default ChatInterface;
src/components/ReportRenderer.tsx CHANGED
@@ -51,51 +51,14 @@ interface Section {
51
  url?: string;
52
  title?: string;
53
  }
54
-
55
  interface ResponseRendererProps {
56
  content: string;
57
  isBotMessage?: boolean;
58
  }
59
 
 
60
  const yugenSysLogo = '/yugensys.png';
61
-
62
- // Function to generate PDF with logo
63
- const generatePdf = (content: string) => {
64
- const doc = new jsPDF();
65
-
66
- const addContentAndSave = () => {
67
- // Add title
68
- doc.setFontSize(20);
69
- doc.text('Report', 60, 25);
70
-
71
- // Add content
72
- doc.setFontSize(12);
73
- const splitText = doc.splitTextToSize(content, 180);
74
- doc.text(splitText, 20, 40);
75
-
76
- // Save the PDF
77
- doc.save('report.pdf');
78
- };
79
-
80
- // Add YugenSys logo to the PDF
81
- const img = new Image();
82
- img.crossOrigin = 'anonymous';
83
- img.onload = () => {
84
- try {
85
- // Add logo to the top left
86
- doc.addImage(img, 'PNG', 15, 10, 40, 15);
87
- } catch {
88
- // If addImage fails, continue without logo
89
- } finally {
90
- addContentAndSave();
91
- }
92
- };
93
- img.onerror = () => {
94
- // If image fails to load, continue without logo
95
- addContentAndSave();
96
- };
97
- img.src = yugenSysLogo;
98
- };
99
 
100
  const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessage = true }) => {
101
  const [expandedSections, setExpandedSections] = React.useState<{ [key: string]: boolean }>({});
@@ -107,6 +70,151 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
107
  }));
108
  };
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  // Parse different content types
111
  const parseContent = (text: string): Section[] => {
112
  const sections: Section[] = [];
@@ -120,20 +228,15 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
120
  continue;
121
  }
122
 
123
- // Headers
124
  if (line.startsWith('# ')) {
125
  sections.push({ type: 'header', content: line.substring(2), level: 1 });
126
  } else if (line.startsWith('## ')) {
127
  sections.push({ type: 'subheader', content: line.substring(3), level: 2 });
128
  } else if (line.startsWith('### ')) {
129
  sections.push({ type: 'subheader', content: line.substring(4), level: 3 });
130
- }
131
- // Bold text as highlights
132
- else if (line.startsWith('**') && line.endsWith('**')) {
133
  sections.push({ type: 'highlight', content: line.substring(2, line.length - 2) });
134
- }
135
- // Lists
136
- else if (line.startsWith('• ') || line.startsWith('- ') || line.startsWith('* ')) {
137
  const listItems = [line.substring(2)];
138
  let j = i + 1;
139
  while (
@@ -145,11 +248,9 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
145
  listItems.push(lines[j].trim().substring(2));
146
  j++;
147
  }
148
- i = j - 1; // Skip processed lines
149
  sections.push({ type: 'list', content: '', items: listItems });
150
- }
151
- // Code blocks
152
- else if (line.startsWith('```')) {
153
  const language = line.substring(3).trim();
154
  let codeContent = '';
155
  let j = i + 1;
@@ -157,11 +258,9 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
157
  codeContent += lines[j] + '\n';
158
  j++;
159
  }
160
- i = j; // Skip processed lines
161
  sections.push({ type: 'code', content: codeContent.trim(), language });
162
- }
163
- // Table detection (simple format: | col1 | col2 |)
164
- else if (line.includes('|') && lines[i + 1]?.includes('|') && lines[i + 1]?.includes('-')) {
165
  const headers = line
166
  .split('|')
167
  .map((h) => h.trim())
@@ -178,9 +277,7 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
178
  }
179
  i = j - 1;
180
  sections.push({ type: 'table', content: '', headers, rows });
181
- }
182
- // Links
183
- else if (line.match(/https?:\/\/[^\s]+/)) {
184
  const urlMatch = line.match(/(https?:\/\/[^\s]+)/);
185
  if (urlMatch) {
186
  const url = urlMatch[1];
@@ -189,13 +286,9 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
189
  } else {
190
  sections.push({ type: 'text', content: line });
191
  }
192
- }
193
- // Key metrics (• Key: Value format)
194
- else if (line.includes('•') && line.includes(':')) {
195
  sections.push({ type: 'metric', content: line });
196
- }
197
- // Regular text
198
- else {
199
  sections.push({ type: 'text', content: line });
200
  }
201
  }
@@ -205,13 +298,6 @@ const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessa
205
 
206
  const sections = parseContent(content);
207
 
208
- // Add download button to the report
209
- const renderDownloadButton = () => (
210
- <Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
211
- <Chip icon={<DownloadIcon />} label="Download as PDF" onClick={() => generatePdf(content)} sx={{ cursor: 'pointer' }} />
212
- </Box>
213
- );
214
-
215
  const renderSection = (section: Section, index: number) => {
216
  const sectionId = `section-${index}`;
217
 
 
51
  url?: string;
52
  title?: string;
53
  }
 
54
  interface ResponseRendererProps {
55
  content: string;
56
  isBotMessage?: boolean;
57
  }
58
 
59
+ // Use absolute paths from the public folder (more reliable than window.location.origin)
60
  const yugenSysLogo = '/yugensys.png';
61
+ const bizInsightsLogo = '/BizInsaights.png'; // NOTE: fixed spelling
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  const ResponseRenderer: React.FC<ResponseRendererProps> = ({ content, isBotMessage = true }) => {
64
  const [expandedSections, setExpandedSections] = React.useState<{ [key: string]: boolean }>({});
 
70
  }));
71
  };
72
 
73
+ // Fetch -> Blob -> downscale on canvas -> DataURL
74
+ const fetchAndDownscale = async (
75
+ src: string,
76
+ maxEdgePx = 600,
77
+ mime: 'image/jpeg' | 'image/png' = 'image/jpeg',
78
+ quality = 0.82
79
+ ): Promise<string | null> => {
80
+ try {
81
+ const res = await fetch(src, { credentials: 'include', cache: 'force-cache' });
82
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${src}`);
83
+ const blob = await res.blob();
84
+ const dataUrl = await new Promise<string>((resolve) => {
85
+ const reader = new FileReader();
86
+ reader.onloadend = () => resolve(reader.result as string);
87
+ reader.readAsDataURL(blob);
88
+ });
89
+
90
+ // Load image from the DataURL
91
+ const img = await new Promise<HTMLImageElement>((resolve, reject) => {
92
+ const i = new Image();
93
+ i.onload = () => resolve(i);
94
+ i.onerror = () => reject(new Error(`Decode failed for ${src}`));
95
+ i.src = dataUrl;
96
+ });
97
+
98
+ const naturalW = (img as any).naturalWidth || img.width;
99
+ const naturalH = (img as any).naturalHeight || img.height;
100
+ if (!naturalW || !naturalH) throw new Error(`Invalid image dimensions for ${src}`);
101
+
102
+ const longest = Math.max(naturalW, naturalH);
103
+ const scale = longest > maxEdgePx ? maxEdgePx / longest : 1;
104
+ const w = Math.max(1, Math.round(naturalW * scale));
105
+ const h = Math.max(1, Math.round(naturalH * scale));
106
+
107
+ const canvas = document.createElement('canvas');
108
+ canvas.width = w;
109
+ canvas.height = h;
110
+ const ctx = canvas.getContext('2d');
111
+ if (!ctx) throw new Error('Canvas 2D context unavailable');
112
+
113
+ if (mime === 'image/jpeg') {
114
+ // White background for transparent PNGs so JPEG looks correct
115
+ ctx.fillStyle = '#ffffff';
116
+ ctx.fillRect(0, 0, w, h);
117
+ }
118
+ ctx.drawImage(img, 0, 0, w, h);
119
+ return canvas.toDataURL(mime, quality);
120
+ } catch (e) {
121
+ console.warn('Logo load/downscale failed:', src, e);
122
+ return null;
123
+ }
124
+ };
125
+
126
+ const generatePdf = (pdfContent: string) => {
127
+ const createPdf = async () => {
128
+ try {
129
+ const doc = new jsPDF({ unit: 'mm', format: 'a4', compress: true });
130
+ doc.setProperties({ title: 'Report' });
131
+
132
+ const pageWidth = doc.internal.pageSize.getWidth();
133
+ const pageHeight = doc.internal.pageSize.getHeight();
134
+ const margin = 15;
135
+ const logoHeight = 15; // mm
136
+ const logoWidth = 40; // mm
137
+
138
+ // Constants for logo handling
139
+ const YUGEN_ALIAS = 'YUGEN_LOGO';
140
+ const BIZ_ALIAS = 'BIZ_LOGO';
141
+
142
+ // Load and prepare logos
143
+ const [yugenDataUrl, bizDataUrl] = await Promise.all([
144
+ fetchAndDownscale(yugenSysLogo, 600, 'image/png'),
145
+ fetchAndDownscale(bizInsightsLogo, 600, 'image/png')
146
+ ]);
147
+
148
+ let headerEmbeddedOnce = false;
149
+
150
+ const addHeader = () => {
151
+ // Left logo
152
+ if (yugenDataUrl) {
153
+ if (!headerEmbeddedOnce) {
154
+ doc.addImage(yugenDataUrl, 'PNG', margin, margin, logoWidth, logoHeight, YUGEN_ALIAS);
155
+ } else {
156
+ // reuse
157
+ doc.addImage(YUGEN_ALIAS, 'PNG', margin, margin, logoWidth, logoHeight);
158
+ }
159
+ }
160
+
161
+ // Right logo
162
+ if (bizDataUrl) {
163
+ const x = pageWidth - margin - logoWidth;
164
+ if (!headerEmbeddedOnce) {
165
+ doc.addImage(bizDataUrl, 'PNG', x, margin, logoWidth, logoHeight, BIZ_ALIAS);
166
+ } else {
167
+ // reuse
168
+ doc.addImage(BIZ_ALIAS, 'PNG', x, margin, logoWidth, logoHeight);
169
+ }
170
+ }
171
+
172
+ headerEmbeddedOnce = true;
173
+
174
+ // Divider line
175
+ doc.setDrawColor(200);
176
+ doc.setLineWidth(0.3);
177
+ doc.line(margin, margin + logoHeight + 4, pageWidth - margin, margin + logoHeight + 4);
178
+ };
179
+
180
+ // 4) First page header
181
+ addHeader();
182
+
183
+
184
+ // Title (still real text)
185
+ doc.setFont('helvetica', 'bold');
186
+ doc.setFontSize(18);
187
+ doc.text('Report', pageWidth / 2, margin + logoHeight + 14, { align: 'center' });
188
+ doc.setFont('helvetica', 'normal');
189
+ doc.setFontSize(12);
190
+
191
+ // Body text (real text, selectable)
192
+ const contentStartY = margin + logoHeight + 24;
193
+ const contentWidth = pageWidth - 2 * margin;
194
+ const split = doc.splitTextToSize(pdfContent, contentWidth);
195
+
196
+ let y = contentStartY;
197
+ const lineHeight = 6; // mm
198
+ for (let i = 0; i < split.length; i++) {
199
+ if (y > pageHeight - margin) {
200
+ doc.addPage();
201
+ addHeader();
202
+ y = margin + logoHeight + 10;
203
+ }
204
+ doc.text(split[i], margin, y);
205
+ y += lineHeight;
206
+ }
207
+
208
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
209
+ doc.save(`report-${timestamp}.pdf`);
210
+ } catch (error) {
211
+ console.error('Error generating PDF:', error);
212
+ }
213
+ };
214
+
215
+ createPdf();
216
+ };
217
+
218
  // Parse different content types
219
  const parseContent = (text: string): Section[] => {
220
  const sections: Section[] = [];
 
228
  continue;
229
  }
230
 
 
231
  if (line.startsWith('# ')) {
232
  sections.push({ type: 'header', content: line.substring(2), level: 1 });
233
  } else if (line.startsWith('## ')) {
234
  sections.push({ type: 'subheader', content: line.substring(3), level: 2 });
235
  } else if (line.startsWith('### ')) {
236
  sections.push({ type: 'subheader', content: line.substring(4), level: 3 });
237
+ } else if (line.startsWith('**') && line.endsWith('**')) {
 
 
238
  sections.push({ type: 'highlight', content: line.substring(2, line.length - 2) });
239
+ } else if (line.startsWith('• ') || line.startsWith('- ') || line.startsWith('* ')) {
 
 
240
  const listItems = [line.substring(2)];
241
  let j = i + 1;
242
  while (
 
248
  listItems.push(lines[j].trim().substring(2));
249
  j++;
250
  }
251
+ i = j - 1;
252
  sections.push({ type: 'list', content: '', items: listItems });
253
+ } else if (line.startsWith('```')) {
 
 
254
  const language = line.substring(3).trim();
255
  let codeContent = '';
256
  let j = i + 1;
 
258
  codeContent += lines[j] + '\n';
259
  j++;
260
  }
261
+ i = j;
262
  sections.push({ type: 'code', content: codeContent.trim(), language });
263
+ } else if (line.includes('|') && lines[i + 1]?.includes('|') && lines[i + 1]?.includes('-')) {
 
 
264
  const headers = line
265
  .split('|')
266
  .map((h) => h.trim())
 
277
  }
278
  i = j - 1;
279
  sections.push({ type: 'table', content: '', headers, rows });
280
+ } else if (line.match(/https?:\/\/[^\s]+/)) {
 
 
281
  const urlMatch = line.match(/(https?:\/\/[^\s]+)/);
282
  if (urlMatch) {
283
  const url = urlMatch[1];
 
286
  } else {
287
  sections.push({ type: 'text', content: line });
288
  }
289
+ } else if (line.includes('•') && line.includes(':')) {
 
 
290
  sections.push({ type: 'metric', content: line });
291
+ } else {
 
 
292
  sections.push({ type: 'text', content: line });
293
  }
294
  }
 
298
 
299
  const sections = parseContent(content);
300
 
 
 
 
 
 
 
 
301
  const renderSection = (section: Section, index: number) => {
302
  const sectionId = `section-${index}`;
303