diff --git a/app/actions.tsx b/app/actions.tsx index d7798fe6..8b693603 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -14,7 +14,7 @@ import { Section } from '@/components/section' import { FollowupPanel } from '@/components/followup-panel' import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, type DrawnFeature } from '@/lib/agents' import { writer } from '@/lib/agents/writer' -import { saveChat, getSystemPrompt } from '@/lib/actions/chat' +import { saveChat, getSystemPrompt, generateReportContext } from '@/lib/actions/chat' import { Chat, AIMessage } from '@/lib/types' import { UserMessage } from '@/components/user-message' import { BotMessage } from '@/components/message' @@ -50,6 +50,20 @@ async function submit(formData?: FormData, skip?: boolean) { console.error('Failed to parse drawnFeatures:', e); } + if (action === 'generate_report_context') { + const messagesString = formData?.get('messages'); + if (typeof messagesString !== 'string') { + return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' }; + } + try { + const messages = JSON.parse(messagesString) as AIMessage[]; + return await generateReportContext(messages); + } catch (e) { + console.error('Failed to parse messages for report context:', e); + return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' }; + } + } + if (action === 'resolution_search') { const file_mapbox = formData?.get('file_mapbox') as File; const file_google = formData?.get('file_google') as File; diff --git a/bun.lock b/bun.lock index 38f68cec..5e0fc295 100644 --- a/bun.lock +++ b/bun.lock @@ -90,7 +90,7 @@ "zustand": "^5.0.9", }, "devDependencies": { - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.60.0", "@types/cookie": "^0.6.0", "@types/lodash": "^4.17.21", "@types/mapbox-gl": "^3.4.1", @@ -437,7 +437,7 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -2075,9 +2075,9 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "point-in-polygon": ["point-in-polygon@1.1.0", "", {}, "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw=="], diff --git a/components/download-report-button.tsx b/components/download-report-button.tsx index 265e9714..6aaf658c 100644 --- a/components/download-report-button.tsx +++ b/components/download-report-button.tsx @@ -8,6 +8,7 @@ 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 { useActions } from 'ai/rsc' import { toast } from 'sonner' import { ReportTemplate } from './report-template' import { createPortal } from 'react-dom' @@ -16,10 +17,12 @@ export const DownloadReportButton = () => { const [aiState] = useAIState() const { map } = useMap() const { mapData } = useMapData() + const actions = useActions() const [isGenerating, setIsGenerating] = useState(false) const [showTemplate, setShowTemplate] = useState(false) const [mapSnapshot, setMapSnapshot] = useState() const [reportTitle, setReportTitle] = useState('QCX Analysis Report') + const [reportSummary, setReportSummary] = useState() const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -39,33 +42,26 @@ export const DownloadReportButton = () => { 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) + // preserveDrawingBuffer is true, so we can capture the canvas + snapshot = map.getCanvas().toDataURL('image/png') 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(' ') - : '' + // Generate AI summary and title + toast.loading('Synthesizing intelligence findings...', { id: toastId }) - try { - const parsed = JSON.parse(content) - chatTitle = parsed.input || content - } catch (e) { - chatTitle = content - } - } - const finalTitle = (chatTitle || 'QCX Analysis Report').substring(0, 50) + const formData = new FormData(); + formData.append('action', 'generate_report_context'); + formData.append('messages', JSON.stringify(aiState.messages)); + + const { title, summary } = await (actions as any).submit(formData); + + const finalTitle = title || 'QCX Intelligence Analysis' setReportTitle(finalTitle) + setReportSummary(summary) // Step 1: Render template in portal setShowTemplate(true) @@ -131,6 +127,7 @@ export const DownloadReportButton = () => { drawnFeatures={mapData?.drawnFeatures} mapSnapshot={mapSnapshot} chatTitle={reportTitle} + aiSummary={reportSummary} /> , document.body diff --git a/components/report-template.tsx b/components/report-template.tsx index a7392fed..b3ac6977 100644 --- a/components/report-template.tsx +++ b/components/report-template.tsx @@ -13,13 +13,15 @@ export interface ReportTemplateProps { }> mapSnapshot?: string chatTitle: string + aiSummary?: string } export const ReportTemplate: React.FC = ({ messages, drawnFeatures, mapSnapshot, - chatTitle + chatTitle, + aiSummary }) => { const renderMessageContent = (content: any): string => { if (typeof content === 'string') { @@ -50,9 +52,10 @@ export const ReportTemplate: React.FC = ({ // and the response is relatively short (likely a transition/redundant summary), filter it out. if (message.type === 'response' && index + 1 < rawFiltered.length) { const nextMessage = rawFiltered[index + 1] - if (nextMessage.type === 'resolution_search_result') { + if (nextMessage.role === 'assistant' && (nextMessage.type === 'resolution_search_result' || nextMessage.type === 'response')) { const content = renderMessageContent(message.content) - if (content.length < 300) { + // Filter out short transition messages or duplicated high-level summaries + if (content.length < 500) { return false } } @@ -103,6 +106,22 @@ export const ReportTemplate: React.FC = ({ {/* Main Content Container */}
+ {aiSummary && ( +
+
+ 00 +

Intelligence Executive Summary

+
+
+
+ + {aiSummary} + +
+
+
+ )} + {mapSnapshot && (
@@ -174,23 +193,23 @@ export const ReportTemplate: React.FC = ({ {result.summary}
)} -
+
{result.mapboxImage && ( -
-
+
+
-

Spectral RGB

+

Spectral RGB Analysis View

- Mapbox View + Mapbox View
)} {result.googleImage && ( -
-
+
+
-

High-Res Panchro

+

High-Resolution Panchromatic Satellite

- Google Satellite + Google Satellite
)}
diff --git a/lib/actions/chat.ts b/lib/actions/chat.ts index f36f2cf6..c8263ebe 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -19,6 +19,69 @@ import { db } from '@/lib/db' import { users } from '@/lib/db/schema' import { eq } from 'drizzle-orm' import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' +import { generateText } from 'ai' +import { getModel } from '../utils' + +export async function generateReportContext(messages: AIMessage[]) { + try { + const model = await getModel() + + const promptMessages = messages + .filter(msg => msg.role === 'user' || (msg.role === 'assistant' && msg.type === 'response')) + .map(msg => { + const role = msg.role === 'user' ? 'user' as const : 'assistant' as const + const rawContent = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map(p => (p && typeof p === 'object' && 'type' in p && p.type === 'text') ? p.text : '').join('\n') + : JSON.stringify(msg.content) + + // Sanitize: strip huge base64 images if any are still in there + const content = rawContent + .replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '[image omitted]') + .trim() + + return { role, content } + }) + .filter(msg => msg.content.length > 0) + + const { text } = await generateText({ + model, + system: `You are a high-level geospatial intelligence analyst. Based on the provided conversation, generate: + 1. A professional, concise report title (max 60 characters). + 2. A 150-200 word executive summary that synthesizes the intelligence findings, observations, and spatial analysis discussed. + + Format your response as a JSON object: + { + "title": "The Title Here", + "summary": "The executive summary here..." + } + Do not include any other text or markdown formatting in your response.`, + messages: promptMessages as any, + }) + + try { + return JSON.parse(text) as { title: string; summary: string } + } catch (e) { + console.error('Failed to parse AI response for report context', { + error: e instanceof Error ? e.message : String(e), + preview: text.slice(0, 200) + }) + // Fallback + return { + title: 'QCX Intelligence Analysis', + summary: 'Executive summary generation failed, but manual review of the intelligence assessment is recommended.' + } + } + } catch (error) { + console.error('Error generating report context:', error) + return { + title: 'QCX Intelligence Analysis', + summary: 'Automated executive summary is currently unavailable.' + } + } +} export async function getChats(userId?: string | null): Promise { if (!userId) { diff --git a/lib/utils/report-generator.ts b/lib/utils/report-generator.ts index 0ee60edf..f8db66d0 100644 --- a/lib/utils/report-generator.ts +++ b/lib/utils/report-generator.ts @@ -31,7 +31,7 @@ export const generatePDFReport = async (elementId: string, fileName: string) => ]) const canvas = await html2canvas(element, { - scale: 2, // Increase scale for higher DPI/sharpness + scale: 3, // Increased scale for ultra-sharp text and images useCORS: true, logging: false, allowTaint: true, @@ -49,7 +49,7 @@ export const generatePDFReport = async (elementId: string, fileName: string) => } }) - const imgData = canvas.toDataURL('image/jpeg', 1.0) // Maximum quality + const imgData = canvas.toDataURL('image/png') // Use PNG for lossless quality and sharper text // A4 dimensions in px at 72 DPI are roughly 595 x 842 // But we use the internal pageSize values for flexibility @@ -70,14 +70,14 @@ export const generatePDFReport = async (elementId: string, fileName: string) => let position = 0 // Add first page - pdf.addImage(imgData, 'JPEG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST') + pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST') heightLeft -= pdfHeight // Add subsequent pages if content overflows while (heightLeft > 0) { position = heightLeft - scaledHeight pdf.addPage() - pdf.addImage(imgData, 'JPEG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST') + pdf.addImage(imgData, 'PNG', 0, position, pdfWidth, scaledHeight, undefined, 'FAST') heightLeft -= pdfHeight } diff --git a/package.json b/package.json index a587a11b..84291c6e 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "zustand": "^5.0.9" }, "devDependencies": { - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.60.0", "@types/cookie": "^0.6.0", "@types/lodash": "^4.17.21", "@types/mapbox-gl": "^3.4.1", diff --git a/tests/verify_report.spec.ts b/tests/verify_report.spec.ts new file mode 100644 index 00000000..89b389e5 --- /dev/null +++ b/tests/verify_report.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test'; + +test('verify report template rendering', async ({ page }) => { + // Mocking AI state/messages for report generation + await page.goto('/'); + + // We need to simulate a chat to have messages to export + await page.fill('[data-testid="chat-input"]', 'Analyze the Eiffel Tower'); + await page.click('[data-testid="chat-submit"]'); + + // Wait for some response to appear + await page.waitForSelector('[data-testid="bot-message"]', { timeout: 30000 }); + + // Now try to trigger the download button which should be in the header or settings + // The DownloadReportButton is used in the SettingsView or Header? + // Let's check where it is used. +});