-
-
Notifications
You must be signed in to change notification settings - Fork 8
PDF Report Generation with HTML Styling #638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3663f8f
b1d2e0e
fae859e
5b42657
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| 'use client' | ||
|
|
||
| import React, { useState, useEffect } from 'react' | ||
| import { Button } from '@/components/ui/button' | ||
| import { FileText, Loader2 } from 'lucide-react' | ||
| import { useAIState } from 'ai/rsc' | ||
| import { useMap } from './map/map-context' | ||
| import { useMapData } from './map/map-data-context' | ||
| import { generatePDFReport } from '@/lib/utils/report-generator' | ||
| import { AI } from '@/app/actions' | ||
| import { toast } from 'sonner' | ||
| import { ReportTemplate } from './report-template' | ||
| import { createPortal } from 'react-dom' | ||
|
|
||
| export const DownloadReportButton = () => { | ||
| const [aiState] = useAIState<typeof AI>() | ||
| const { map } = useMap() | ||
| const { mapData } = useMapData() | ||
| const [isGenerating, setIsGenerating] = useState(false) | ||
| const [showTemplate, setShowTemplate] = useState(false) | ||
| const [mapSnapshot, setMapSnapshot] = useState<string | undefined>() | ||
| const [reportTitle, setReportTitle] = useState('QCX Analysis Report') | ||
| const [isMounted, setIsMounted] = useState(false) | ||
|
|
||
| useEffect(() => { | ||
| setIsMounted(true) | ||
| }, []) | ||
|
|
||
| const handleDownload = async () => { | ||
| if (!aiState || aiState.messages.length === 0) { | ||
| toast.error('No conversation to export') | ||
| return | ||
| } | ||
|
|
||
| setIsGenerating(true) | ||
| const toastId = toast.loading('Preparing report generation...') | ||
|
|
||
| try { | ||
| let snapshot: string | undefined | ||
| if (map) { | ||
| try { | ||
| // Use JPEG to keep the data URL smaller and potentially avoid context loss | ||
| snapshot = map.getCanvas().toDataURL('image/jpeg', 0.6) | ||
| setMapSnapshot(snapshot) | ||
| } catch (e) { | ||
| console.warn('Failed to capture map snapshot', e) | ||
| } | ||
| } | ||
|
|
||
| // Extract title more robustly | ||
| let chatTitle = 'Untitled Chat' | ||
| if (aiState.messages.length > 0) { | ||
| const firstMessage = aiState.messages[0] | ||
| const content = typeof firstMessage.content === 'string' | ||
| ? firstMessage.content | ||
| : Array.isArray(firstMessage.content) | ||
| ? (firstMessage.content as any[]).map(p => p.type === 'text' ? p.text : '').join(' ') | ||
| : '' | ||
|
|
||
| try { | ||
| const parsed = JSON.parse(content) | ||
| chatTitle = parsed.input || content | ||
| } catch (e) { | ||
| chatTitle = content | ||
| } | ||
| } | ||
| const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50) | ||
| setReportTitle(finalTitle) | ||
|
|
||
| // Step 1: Render template in portal | ||
| setShowTemplate(true) | ||
|
|
||
| // Step 2: Wait for DOM and React to synchronize | ||
| // Using a longer timeout to ensure large images and complex content are rendered | ||
| toast.loading('Rendering report elements...', { id: toastId }) | ||
| await new Promise(resolve => setTimeout(resolve, 1500)) | ||
|
|
||
| // Step 3: Capture and Generate | ||
| toast.loading('Capturing styled report...', { id: toastId }) | ||
| await generatePDFReport('report-template', finalTitle) | ||
|
Comment on lines
+70
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed 1500ms timeout remains fragile for ensuring DOM readiness. While increased from the previous 500ms, using Consider using a double 🛡️ Proposed fix using double rAF // Step 1: Render template in portal
setShowTemplate(true)
- // Step 2: Wait for DOM and React to synchronize
- // Using a longer timeout to ensure large images and complex content are rendered
- toast.loading('Rendering report elements...', { id: toastId })
- await new Promise(resolve => setTimeout(resolve, 1500))
+ // Step 2: Wait for React commit + browser paint
+ toast.loading('Rendering report elements...', { id: toastId })
+ await new Promise<void>(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve())
+ })
+ })
+
+ // Step 2b: Wait for images in the template to decode
+ const templateEl = document.getElementById('report-template')
+ if (templateEl) {
+ const imgs = Array.from(templateEl.getElementsByTagName('img'))
+ await Promise.all(
+ imgs.map(img => img.decode?.().catch(() => {}))
+ )
+ }
// Step 3: Capture and Generate🤖 Prompt for AI Agents |
||
|
|
||
| toast.success('Report generated successfully', { id: toastId }) | ||
| } catch (error) { | ||
| console.error('Failed to generate report:', error) | ||
| toast.error(`Report generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { id: toastId }) | ||
| } finally { | ||
| setIsGenerating(false) | ||
| setShowTemplate(false) | ||
| setMapSnapshot(undefined) | ||
| } | ||
| } | ||
|
|
||
| if (!isMounted) return null | ||
|
|
||
| return ( | ||
| <> | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| onClick={handleDownload} | ||
| disabled={isGenerating || !aiState || aiState.messages.length === 0} | ||
| title="Download PDF Report" | ||
| className="relative" | ||
| > | ||
| {isGenerating ? ( | ||
| <Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" /> | ||
| ) : ( | ||
| <FileText className="h-[1.2rem] w-[1.2rem]" /> | ||
| )} | ||
| <span className="sr-only">Download Report</span> | ||
| </Button> | ||
|
|
||
| {/* Hidden container for PDF rendering - ensure it's in the DOM with appropriate styles */} | ||
| {showTemplate && createPortal( | ||
| <div | ||
| id="pdf-render-container" | ||
| style={{ | ||
| position: 'fixed', | ||
| left: '-10000px', | ||
| top: 0, | ||
| width: '800px', | ||
| zIndex: -9999, | ||
| backgroundColor: 'white', | ||
| color: 'black', | ||
| visibility: 'visible', | ||
| pointerEvents: 'none' | ||
| }} | ||
| > | ||
| <ReportTemplate | ||
| messages={aiState.messages} | ||
| drawnFeatures={mapData?.drawnFeatures} | ||
| mapSnapshot={mapSnapshot} | ||
| chatTitle={reportTitle} | ||
| /> | ||
| </div>, | ||
| document.body | ||
| )} | ||
| </> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import React from 'react' | ||
| import { AIMessage } from '@/lib/types' | ||
| import ReactMarkdown from 'react-markdown' | ||
| import remarkGfm from 'remark-gfm' | ||
|
|
||
| export interface ReportTemplateProps { | ||
| messages: AIMessage[] | ||
| drawnFeatures?: Array<{ | ||
| id: string | ||
| type: 'Polygon' | 'LineString' | ||
| measurement: string | ||
| geometry: any | ||
| }> | ||
| mapSnapshot?: string | ||
| chatTitle: string | ||
| } | ||
|
|
||
| export const ReportTemplate: React.FC<ReportTemplateProps> = ({ | ||
| messages, | ||
| drawnFeatures, | ||
| mapSnapshot, | ||
| chatTitle | ||
| }) => { | ||
| const filteredMessages = messages.filter(m => | ||
| m.type === 'input' || | ||
| m.type === 'input_related' || | ||
| m.type === 'response' || | ||
| m.type === 'resolution_search_result' | ||
| ) | ||
|
|
||
| const renderMessageContent = (content: any): string => { | ||
| if (typeof content === 'string') { | ||
| return content | ||
| } | ||
| if (Array.isArray(content)) { | ||
| return content | ||
| .map(part => { | ||
| if (typeof part === 'string') return part | ||
| if (part && typeof part === 'object' && part.type === 'text') return part.text | ||
| return '' | ||
| }) | ||
| .join('\n') | ||
| } | ||
| return '' | ||
| } | ||
|
|
||
| return ( | ||
| <div id="report-template" className="p-8 bg-white text-black font-sans max-w-4xl mx-auto border border-gray-200"> | ||
| <header className="mb-8 border-b-2 border-[#1a1a1a] pb-4"> | ||
| <h1 className="text-3xl font-bold mb-2">{chatTitle}</h1> | ||
| <p className="text-gray-600">Generated on: {new Date().toLocaleString()}</p> | ||
| </header> | ||
|
|
||
| {mapSnapshot && ( | ||
| <section className="mb-10"> | ||
| <h2 className="text-xl font-semibold mb-4 border-l-4 border-blue-600 pl-2">Live Map View</h2> | ||
| <div className="border rounded-lg overflow-hidden shadow-sm bg-gray-100 min-h-[200px] flex items-center justify-center"> | ||
| <img src={mapSnapshot} alt="Map Snapshot" className="w-full h-auto block" crossOrigin="anonymous" /> | ||
| </div> | ||
| </section> | ||
| )} | ||
|
|
||
| <section className="mb-10"> | ||
| <h2 className="text-xl font-semibold mb-6 border-l-4 border-blue-600 pl-2">Conversation History</h2> | ||
| <div className="space-y-8"> | ||
| {filteredMessages.map((message, index) => { | ||
| const contentString = renderMessageContent(message.content) | ||
|
|
||
| if (message.type === 'input' || message.type === 'input_related') { | ||
| let content = '' | ||
| try { | ||
| const json = JSON.parse(contentString) | ||
| content = message.type === 'input' ? (json.input || contentString) : (json.related_query || contentString) | ||
| } catch (e) { | ||
| content = contentString | ||
| } | ||
| return ( | ||
| <div key={index} className="bg-gray-50 p-4 rounded-lg border-l-4 border-blue-400"> | ||
| <p className="text-sm font-bold text-blue-600 mb-1">User Question</p> | ||
| <p className="text-gray-800 italic">{content}</p> | ||
| </div> | ||
|
Comment on lines
+69
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Image message report crash ReportTemplate assumes message.content is a string for input/input_related messages; when it is actually an array of content parts (text/image), the catch-path assigns the array to content and then renders it, which throws at runtime and prevents PDF generation. This is triggered by resolution search and any chat input that includes an image attachment, since those messages are stored with array content. Agent Prompt
|
||
| ) | ||
| } else if (message.type === 'response') { | ||
| return ( | ||
| <div key={index} className="prose prose-sm max-w-none border-b border-gray-100 pb-4"> | ||
| <p className="text-sm font-bold text-green-600 mb-1">AI Response</p> | ||
| <div className="text-gray-800"> | ||
| <ReactMarkdown remarkPlugins={[remarkGfm]}> | ||
| {contentString} | ||
| </ReactMarkdown> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } else if (message.type === 'resolution_search_result') { | ||
| try { | ||
| const result = JSON.parse(contentString) | ||
| return ( | ||
| <div key={index} className="space-y-4 bg-purple-50 p-4 rounded-lg"> | ||
| <p className="text-sm font-bold text-purple-600 mb-1">Analysis Result</p> | ||
| {result.summary && ( | ||
| <div className="text-gray-800 mb-4"> | ||
| {result.summary} | ||
| </div> | ||
| )} | ||
| <div className="grid grid-cols-2 gap-4"> | ||
| {result.mapboxImage && ( | ||
| <div className="space-y-1"> | ||
| <p className="text-[10px] text-gray-500 font-semibold uppercase">Mapbox View</p> | ||
| <img src={result.mapboxImage} alt="Mapbox View" className="rounded border border-purple-200 w-full block" crossOrigin="anonymous" /> | ||
| </div> | ||
| )} | ||
| {result.googleImage && ( | ||
| <div className="space-y-1"> | ||
| <p className="text-[10px] text-gray-500 font-semibold uppercase">Google Satellite</p> | ||
| <img src={result.googleImage} alt="Google Satellite" className="rounded border border-purple-200 w-full block" crossOrigin="anonymous" /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } catch (e) { | ||
| return null | ||
| } | ||
| } | ||
| return null | ||
| })} | ||
| </div> | ||
| </section> | ||
|
|
||
| {drawnFeatures && drawnFeatures.length > 0 && ( | ||
| <section className="mt-10 border-t-2 border-gray-100 pt-6"> | ||
| <h2 className="text-xl font-semibold mb-4 border-l-4 border-orange-500 pl-2">Appendix: Drawn Features & Measurements</h2> | ||
| <div className="overflow-x-auto"> | ||
| <table className="min-w-full divide-y divide-gray-200 text-sm"> | ||
| <thead className="bg-gray-50"> | ||
| <tr> | ||
| <th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Type</th> | ||
| <th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Measurement</th> | ||
| <th className="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Geometry</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="bg-white divide-y divide-gray-200"> | ||
| {drawnFeatures.map((feature) => ( | ||
| <tr key={feature.id}> | ||
| <td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.type}</td> | ||
| <td className="px-4 py-2 whitespace-nowrap text-gray-900">{feature.measurement}</td> | ||
| <td className="px-4 py-2 text-gray-500 break-all font-mono text-[10px]"> | ||
| {JSON.stringify(feature.geometry.coordinates).substring(0, 100)}... | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </section> | ||
| )} | ||
|
|
||
| <footer className="mt-12 text-center text-gray-400 text-xs border-t pt-4"> | ||
| <p>© {new Date().getFullYear()} QCX - Planet Computer Analysis Report</p> | ||
| </footer> | ||
| </div> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
Unsafe
as any[]cast; consider narrowing the type.The cast discards type information and could mask runtime issues if the AI SDK changes the content structure. A type guard or explicit interface would improve safety.
♻️ Proposed type-safe alternative
📝 Committable suggestion
🤖 Prompt for AI Agents