From 1f470167c09185d34d68db4889f8797223168138 Mon Sep 17 00:00:00 2001 From: Vineeth Kalluru Date: Fri, 26 Sep 2025 08:13:49 -0700 Subject: [PATCH 1/5] Merging with main branch Signed-off-by: Vineeth Kalluru --- components/Chat/ChatMessage.tsx | 53 +++++- components/Chat/HtmlFileRenderer.tsx | 249 +++++++++++++++++++++++++++ pages/api/load-html-file.ts | 59 +++++++ utils/app/htmlFileDetector.ts | 201 +++++++++++++++++++++ 4 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 components/Chat/HtmlFileRenderer.tsx create mode 100644 pages/api/load-html-file.ts create mode 100644 utils/app/htmlFileDetector.ts diff --git a/components/Chat/ChatMessage.tsx b/components/Chat/ChatMessage.tsx index dbfd8a5..b32a518 100644 --- a/components/Chat/ChatMessage.tsx +++ b/components/Chat/ChatMessage.tsx @@ -33,6 +33,9 @@ import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; +import { HtmlFileRenderer } from './HtmlFileRenderer'; +import { detectHtmlFileLinks, removeHtmlFileLinksFromContent, HtmlFileLink } from '@/utils/app/htmlFileDetector'; + export interface Props { message: Message; messageIndex: number; @@ -182,6 +185,12 @@ export const ChatMessage: FC = memo( }; }, []); + // Detect HTML files in assistant messages + const htmlFileLinks: HtmlFileLink[] = + message.role === 'assistant' + ? detectHtmlFileLinks(message.content) + : []; + const prepareContent = ({ message = {} as Message, responseContent = true, @@ -205,6 +214,32 @@ export const ChatMessage: FC = memo( return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, '\n '); }; + // Prepare content with HTML file links removed to avoid duplicate display + const prepareContentWithoutHtmlLinks = ({ + message = {} as Message, + responseContent = true, + intermediateStepsContent = false, + role = 'assistant', + } = {}) => { + const { content = '', intermediateSteps = [] } = message; + + if (role === 'user') return content.trim(); + + let result = ''; + if (intermediateStepsContent) { + result += generateContentIntermediate(intermediateSteps); + } + + if (responseContent) { + // Remove HTML file links from content + const cleanContent = removeHtmlFileLinksFromContent(content); + result += result ? `\n\n${cleanContent}` : cleanContent; + } + + // fixing malformed html and removing extra spaces to avoid markdown issues + return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, '\n '); + }; + return (
= memo( linkTarget="_blank" components={markdownComponents} > - {prepareContent({ + {prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: true, @@ -341,7 +376,7 @@ export const ChatMessage: FC = memo( linkTarget="_blank" components={markdownComponents} > - {prepareContent({ + {prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: false, @@ -349,6 +384,20 @@ export const ChatMessage: FC = memo( })}
+ {/* HTML File Renderers */} + {htmlFileLinks.length > 0 && ( +
+ {htmlFileLinks.map((htmlFile, index) => ( + + ))} +
+ )}
{!messageIsStreaming && ( <> diff --git a/components/Chat/HtmlFileRenderer.tsx b/components/Chat/HtmlFileRenderer.tsx new file mode 100644 index 0000000..011d334 --- /dev/null +++ b/components/Chat/HtmlFileRenderer.tsx @@ -0,0 +1,249 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { IconEye, IconEyeOff, IconExternalLink, IconFile, IconDownload } from '@tabler/icons-react'; + +interface HtmlFileRendererProps { + filePath: string; + title?: string; + isInlineHtml?: boolean; + htmlContent?: string; +} + +export const HtmlFileRenderer: React.FC = ({ + filePath, + title, + isInlineHtml = false, + htmlContent: inlineHtmlContent +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [htmlContent, setHtmlContent] = useState(inlineHtmlContent || ''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const cleanFilePath = (path: string): string => { + // For inline HTML, return a descriptive name + if (isInlineHtml) { + return title || 'Inline HTML Content'; + } + + // Remove any malformed prefixes or HTML artifacts + let cleaned = path.replace(/^.*?href=["']?/, ''); + cleaned = cleaned.replace(/["'>].*$/, ''); + + // Remove file:// prefix for API call + cleaned = cleaned.replace('file://', ''); + + return cleaned; + }; + + const loadHtmlContent = async () => { + // If it's inline HTML, content is already provided + if (isInlineHtml && inlineHtmlContent) { + setHtmlContent(inlineHtmlContent); + return; + } + + if (isExpanded && !htmlContent && !error) { + setIsLoading(true); + setError(''); + + try { + const cleanPath = cleanFilePath(filePath); + console.log('Loading HTML file via API:', cleanPath); + + const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`); + + if (!response.ok) { + throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); + } + + const content = await response.text(); + setHtmlContent(content); + } catch (err: any) { + console.error('Error loading HTML file:', err); + setError(`Failed to load HTML file: ${err.message}`); + } finally { + setIsLoading(false); + } + } + }; + + useEffect(() => { + if (isExpanded) { + loadHtmlContent(); + } + }, [isExpanded, filePath, isInlineHtml, inlineHtmlContent]); + + const openInSystemBrowser = () => { + if (isInlineHtml) { + // For inline HTML, create a blob URL and try to open it + try { + const blob = new Blob([inlineHtmlContent || ''], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + // Clean up the URL after a delay + setTimeout(() => URL.revokeObjectURL(url), 10000); + } catch (error) { + console.error('Error opening inline HTML:', error); + alert('Unable to open inline HTML content in new window.'); + } + return; + } + + const cleanPath = cleanFilePath(filePath); + // Try to open in system file manager/browser + try { + // For desktop apps or Electron, this might work + if ((window as any).electronAPI) { + (window as any).electronAPI.openFile(cleanPath); + } else { + // Provide instructions to user + alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}\n\nYou can copy this path and paste it into your browser's address bar.`); + } + } catch (error) { + console.error('Error opening file:', error); + alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}`); + } + }; + + const copyPathToClipboard = async () => { + try { + if (isInlineHtml) { + // For inline HTML, copy the HTML content itself + await navigator.clipboard.writeText(inlineHtmlContent || ''); + alert('HTML content copied to clipboard!'); + } else { + const cleanPath = cleanFilePath(filePath); + await navigator.clipboard.writeText(cleanPath); + alert('File path copied to clipboard! Paste it into your browser address bar to view the plot.'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + if (isInlineHtml) { + alert('Failed to copy HTML content to clipboard.'); + } else { + const cleanPath = cleanFilePath(filePath); + alert(`Copy this path to your browser:\n\n${cleanPath}`); + } + } + }; + + const displayPath = cleanFilePath(filePath); + + return ( +
+ {/* Header */} +
+
+ +
+ + {title || (isInlineHtml ? 'Inline HTML Content' : 'Interactive Plot')} + +
+ + {isInlineHtml ? 'Inline HTML' : 'HTML Plot'} + + + {isInlineHtml ? 'HTML response content' : 'Interactive Bokeh visualization'} + +
+
+
+ +
+ + + + + {!isInlineHtml && ( + + )} + + {isInlineHtml && ( + + )} +
+
+ + {/* Content */} + {isExpanded && ( +
+ {isLoading && ( +
+
+ Loading {isInlineHtml ? 'content' : 'plot'}... +
+ )} + + {error && !isInlineHtml && ( +
+

Could not load plot inline

+

{error}

+
+

+ To view this plot: +

+
    +
  1. Click the copy button above to copy the file path
  2. +
  3. Open a new browser tab
  4. +
  5. Paste the path into the address bar
  6. +
  7. Press Enter to view the interactive plot
  8. +
+ +
+
+ )} + + {htmlContent && !error && ( +
+