Skip to content

Commit 59dd4ab

Browse files
committed
Plot rendering feature initial
Signed-off-by: Vineeth Kalluru <[email protected]>
1 parent b78e278 commit 59dd4ab

File tree

5 files changed

+558
-4
lines changed

5 files changed

+558
-4
lines changed

components/Chat/Chat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -879,11 +879,11 @@ export const Chat = () => {
879879

880880
return (
881881
<div
882-
className="relative flex-1 overflow-hidden bg-white dark:bg-[#343541] transition-all duration-300 ease-in-out"
882+
className="relative flex-1 overflow-auto bg-white dark:bg-[#343541] transition-all duration-300 ease-in-out"
883883
>
884884
<>
885885
<div
886-
className="max-h-full overflow-x-hidden"
886+
className="max-h-full overflow-x-hidden overflow-y-auto"
887887
ref={chatContainerRef}
888888
onScroll={handleScroll}
889889
>

components/Chat/ChatMessage.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import ReactMarkdown from 'react-markdown';
2121
import rehypeRaw from 'rehype-raw';
2222
import { getReactMarkDownCustomComponents } from '../Markdown/CustomComponents';
2323
import { fixMalformedHtml, generateContentIntermediate } from '@/utils/app/helper';
24+
import { HtmlFileRenderer } from './HtmlFileRenderer';
25+
import { detectHtmlFileLinks, removeHtmlFileLinksFromContent, HtmlFileLink } from '@/utils/app/htmlFileDetector';
2426

2527
export interface Props {
2628
message: Message;
@@ -188,6 +190,35 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit}) =>
188190
return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, "\n ");
189191
};
190192

193+
// Detect HTML files in assistant messages
194+
const htmlFileLinks: HtmlFileLink[] = message.role === 'assistant' ? detectHtmlFileLinks(message.content) : [];
195+
196+
// Prepare content with HTML file links removed to avoid duplicate display
197+
const prepareContentWithoutHtmlLinks = ({
198+
message = {},
199+
responseContent = true,
200+
intermediateStepsContent = false,
201+
role = 'assistant'
202+
} = {}) => {
203+
const { content = '', intermediateSteps = [] } = message;
204+
205+
if (role === 'user') return content.trim();
206+
207+
let result = '';
208+
if (intermediateStepsContent) {
209+
result += generateContentIntermediate(intermediateSteps);
210+
}
211+
212+
if (responseContent) {
213+
// Remove HTML file links from content
214+
const cleanContent = removeHtmlFileLinksFromContent(content);
215+
result += result ? `\n\n${cleanContent}` : cleanContent;
216+
}
217+
218+
// fixing malformed html and removing extra spaces to avoid markdown issues
219+
return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, "\n ");
220+
};
221+
191222
return (
192223
<div
193224
className={`group md:px-4 ${
@@ -296,7 +327,7 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit}) =>
296327
linkTarget="_blank"
297328
components={getReactMarkDownCustomComponents(messageIndex, message?.id)}
298329
>
299-
{prepareContent({ message, role: 'assistant', intermediateStepsContent: true, responseContent: false })}
330+
{prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: true, responseContent: false })}
300331
</MemoizedReactMarkdown>
301332
</div>
302333
{/* for response content */}
@@ -313,9 +344,23 @@ export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit}) =>
313344
linkTarget="_blank"
314345
components={getReactMarkDownCustomComponents(messageIndex, message?.id)}
315346
>
316-
{prepareContent({ message, role: 'assistant', intermediateStepsContent: false, responseContent: true })}
347+
{prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: false, responseContent: true })}
317348
</MemoizedReactMarkdown>
318349
</div>
350+
{/* HTML File Renderers */}
351+
{htmlFileLinks.length > 0 && (
352+
<div className="mt-4">
353+
{htmlFileLinks.map((htmlFile, index) => (
354+
<HtmlFileRenderer
355+
key={`${htmlFile.filePath}-${index}`}
356+
filePath={htmlFile.filePath}
357+
title={htmlFile.title}
358+
isInlineHtml={htmlFile.isInlineHtml}
359+
htmlContent={htmlFile.htmlContent}
360+
/>
361+
))}
362+
</div>
363+
)}
319364
<div className="mt-1 flex gap-1">
320365
{
321366
!messageIsStreaming &&
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
'use client';
2+
import React, { useState, useEffect } from 'react';
3+
import { IconEye, IconEyeOff, IconExternalLink, IconFile, IconDownload } from '@tabler/icons-react';
4+
5+
interface HtmlFileRendererProps {
6+
filePath: string;
7+
title?: string;
8+
isInlineHtml?: boolean;
9+
htmlContent?: string;
10+
}
11+
12+
export const HtmlFileRenderer: React.FC<HtmlFileRendererProps> = ({
13+
filePath,
14+
title,
15+
isInlineHtml = false,
16+
htmlContent: inlineHtmlContent
17+
}) => {
18+
const [isExpanded, setIsExpanded] = useState<boolean>(false);
19+
const [htmlContent, setHtmlContent] = useState<string>(inlineHtmlContent || '');
20+
const [isLoading, setIsLoading] = useState<boolean>(false);
21+
const [error, setError] = useState<string>('');
22+
23+
const cleanFilePath = (path: string): string => {
24+
// For inline HTML, return a descriptive name
25+
if (isInlineHtml) {
26+
return title || 'Inline HTML Content';
27+
}
28+
29+
// Remove any malformed prefixes or HTML artifacts
30+
let cleaned = path.replace(/^.*?href=["']?/, '');
31+
cleaned = cleaned.replace(/["'>].*$/, '');
32+
33+
// Remove file:// prefix for API call
34+
cleaned = cleaned.replace('file://', '');
35+
36+
return cleaned;
37+
};
38+
39+
const loadHtmlContent = async () => {
40+
// If it's inline HTML, content is already provided
41+
if (isInlineHtml && inlineHtmlContent) {
42+
setHtmlContent(inlineHtmlContent);
43+
return;
44+
}
45+
46+
if (isExpanded && !htmlContent && !error) {
47+
setIsLoading(true);
48+
setError('');
49+
50+
try {
51+
const cleanPath = cleanFilePath(filePath);
52+
console.log('Loading HTML file via API:', cleanPath);
53+
54+
const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`);
55+
56+
if (!response.ok) {
57+
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
58+
}
59+
60+
const content = await response.text();
61+
setHtmlContent(content);
62+
} catch (err: any) {
63+
console.error('Error loading HTML file:', err);
64+
setError(`Failed to load HTML file: ${err.message}`);
65+
} finally {
66+
setIsLoading(false);
67+
}
68+
}
69+
};
70+
71+
useEffect(() => {
72+
if (isExpanded) {
73+
loadHtmlContent();
74+
}
75+
}, [isExpanded, filePath, isInlineHtml, inlineHtmlContent]);
76+
77+
const openInSystemBrowser = () => {
78+
if (isInlineHtml) {
79+
// For inline HTML, create a blob URL and try to open it
80+
try {
81+
const blob = new Blob([inlineHtmlContent || ''], { type: 'text/html' });
82+
const url = URL.createObjectURL(blob);
83+
window.open(url, '_blank');
84+
// Clean up the URL after a delay
85+
setTimeout(() => URL.revokeObjectURL(url), 10000);
86+
} catch (error) {
87+
console.error('Error opening inline HTML:', error);
88+
alert('Unable to open inline HTML content in new window.');
89+
}
90+
return;
91+
}
92+
93+
const cleanPath = cleanFilePath(filePath);
94+
// Try to open in system file manager/browser
95+
try {
96+
// For desktop apps or Electron, this might work
97+
if ((window as any).electronAPI) {
98+
(window as any).electronAPI.openFile(cleanPath);
99+
} else {
100+
// Provide instructions to user
101+
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.`);
102+
}
103+
} catch (error) {
104+
console.error('Error opening file:', error);
105+
alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}`);
106+
}
107+
};
108+
109+
const copyPathToClipboard = async () => {
110+
try {
111+
if (isInlineHtml) {
112+
// For inline HTML, copy the HTML content itself
113+
await navigator.clipboard.writeText(inlineHtmlContent || '');
114+
alert('HTML content copied to clipboard!');
115+
} else {
116+
const cleanPath = cleanFilePath(filePath);
117+
await navigator.clipboard.writeText(cleanPath);
118+
alert('File path copied to clipboard! Paste it into your browser address bar to view the plot.');
119+
}
120+
} catch (error) {
121+
console.error('Failed to copy to clipboard:', error);
122+
if (isInlineHtml) {
123+
alert('Failed to copy HTML content to clipboard.');
124+
} else {
125+
const cleanPath = cleanFilePath(filePath);
126+
alert(`Copy this path to your browser:\n\n${cleanPath}`);
127+
}
128+
}
129+
};
130+
131+
const displayPath = cleanFilePath(filePath);
132+
133+
return (
134+
<div className="my-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
135+
{/* Header */}
136+
<div className="bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-900/20 dark:to-blue-900/20 px-4 py-3 flex justify-between items-center">
137+
<div className="flex items-center gap-3">
138+
<IconFile size={20} className="text-green-600 dark:text-green-400" />
139+
<div>
140+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
141+
{title || (isInlineHtml ? 'Inline HTML Content' : 'Interactive Plot')}
142+
</span>
143+
<div className="flex items-center gap-2 mt-1">
144+
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
145+
{isInlineHtml ? 'Inline HTML' : 'HTML Plot'}
146+
</span>
147+
<span className="text-xs text-gray-500 dark:text-gray-400">
148+
{isInlineHtml ? 'HTML response content' : 'Interactive Bokeh visualization'}
149+
</span>
150+
</div>
151+
</div>
152+
</div>
153+
154+
<div className="flex items-center gap-2">
155+
<button
156+
onClick={() => setIsExpanded(!isExpanded)}
157+
className="flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors shadow-sm"
158+
>
159+
{isExpanded ? <IconEyeOff size={16} /> : <IconEye size={16} />}
160+
{isExpanded ? 'Hide' : 'Show'} {isInlineHtml ? 'Content' : 'Plot'}
161+
</button>
162+
163+
<button
164+
onClick={copyPathToClipboard}
165+
className="flex items-center gap-1 px-3 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
166+
title={isInlineHtml ? "Copy HTML content" : "Copy file path"}
167+
>
168+
<IconDownload size={16} />
169+
</button>
170+
171+
{!isInlineHtml && (
172+
<button
173+
onClick={openInSystemBrowser}
174+
className="flex items-center gap-1 px-3 py-2 text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors"
175+
title="Open instructions"
176+
>
177+
<IconExternalLink size={16} />
178+
</button>
179+
)}
180+
181+
{isInlineHtml && (
182+
<button
183+
onClick={openInSystemBrowser}
184+
className="flex items-center gap-1 px-3 py-2 text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors"
185+
title="Open in new window"
186+
>
187+
<IconExternalLink size={16} />
188+
</button>
189+
)}
190+
</div>
191+
</div>
192+
193+
{/* Content */}
194+
{isExpanded && (
195+
<div className="p-4">
196+
{isLoading && (
197+
<div className="flex items-center justify-center py-8">
198+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
199+
<span className="ml-2 text-gray-600 dark:text-gray-400">Loading {isInlineHtml ? 'content' : 'plot'}...</span>
200+
</div>
201+
)}
202+
203+
{error && !isInlineHtml && (
204+
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
205+
<p className="text-red-700 dark:text-red-400 text-sm font-medium mb-2">Could not load plot inline</p>
206+
<p className="text-red-600 dark:text-red-500 text-sm mb-3">{error}</p>
207+
<div className="space-y-2">
208+
<p className="text-sm text-gray-700 dark:text-gray-300">
209+
<strong>To view this plot:</strong>
210+
</p>
211+
<ol className="text-sm text-gray-600 dark:text-gray-400 list-decimal list-inside space-y-1">
212+
<li>Click the copy button above to copy the file path</li>
213+
<li>Open a new browser tab</li>
214+
<li>Paste the path into the address bar</li>
215+
<li>Press Enter to view the interactive plot</li>
216+
</ol>
217+
<button
218+
onClick={copyPathToClipboard}
219+
className="mt-3 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md transition-colors"
220+
>
221+
📋 Copy File Path
222+
</button>
223+
</div>
224+
</div>
225+
)}
226+
227+
{htmlContent && !error && (
228+
<div className="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
229+
<iframe
230+
srcDoc={htmlContent}
231+
className="w-full border-0"
232+
sandbox="allow-scripts allow-same-origin"
233+
title={title || (isInlineHtml ? 'Inline HTML Content' : 'HTML Content')}
234+
style={{ height: '600px', minHeight: '500px' }}
235+
/>
236+
</div>
237+
)}
238+
</div>
239+
)}
240+
241+
{/* File path info */}
242+
<div className="bg-gray-50 dark:bg-gray-800/50 px-4 py-2 border-t border-gray-200 dark:border-gray-700">
243+
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
244+
{isInlineHtml ? 'Inline HTML Content' : displayPath}
245+
</span>
246+
</div>
247+
</div>
248+
);
249+
};

0 commit comments

Comments
 (0)