Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { MapDataProvider, useMapData } from './map/map-data-context'; // Add thi
import { updateDrawingContext } from '@/lib/actions/chat'; // Import the server action
import dynamic from 'next/dynamic'
import { HeaderSearchButton } from './header-search-button'
import { ReportButton } from './report-button'

type ChatProps = {
id?: string // This is the chatId
Expand Down
64 changes: 64 additions & 0 deletions components/report-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client'

import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { useAIState } from 'ai/rsc'
import { useMapData } from '@/components/map/map-data-context'
import { useMap } from '@/components/map/map-context'
import { generateReport } from '@/lib/utils/report-generator'
import { toast } from 'sonner'

interface ReportButtonProps {
inline?: boolean
}

export const ReportButton = ({ inline = false }: ReportButtonProps) => {
const [aiState] = useAIState()
const { mapData } = useMapData()
const { map } = useMap()
const [isGenerating, setIsGenerating] = useState(false)

const handleDownloadReport = async () => {
if (isGenerating) return

setIsGenerating(true)
try {
const mapSnapshot = map ? map.getCanvas().toDataURL('image/png') : ''

const chatTitle = aiState.chatId ? `Chat-${aiState.chatId.substring(0, 8)}` : 'QCX-Analysis'

await generateReport({
messages: aiState.messages,
drawnFeatures: mapData.drawnFeatures || [],
mapSnapshot,
chatTitle
})

// Removed success toast as per user request
} catch (error) {
console.error('Failed to generate report:', error)
toast.error('Failed to generate report')
} finally {
setIsGenerating(false)
}
}

return (
<Button
variant={inline ? "default" : "ghost"}
size={inline ? "default" : "icon"}
onClick={handleDownloadReport}
title="Download PDF Report"
disabled={isGenerating}
className={inline ? "w-full" : ""}
>
{isGenerating ? (
<Loader2 className="h-[1.2rem] w-[1.2rem] animate-spin" />
) : (
<FileDown className="h-[1.2rem] w-[1.2rem]" />
)}
{inline && <span className="ml-2">Generate Report</span>}
</Button>
)
}
49 changes: 30 additions & 19 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat"
import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users"
import { useCurrentUser } from "@/lib/auth/use-current-user"
import { SettingsSkeleton } from './settings-skeleton'
import { ReportButton } from '@/components/report-button'

// Define the form schema with enum validation for roles
const settingsFormSchema = z.object({
Expand Down Expand Up @@ -166,7 +167,7 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
<Tabs.Trigger value="system-prompt" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">System Prompt</Tabs.Trigger>
<Tabs.Trigger value="model" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Model Selection</Tabs.Trigger>
<Tabs.Trigger value="user-management" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">User Management</Tabs.Trigger>
<Tabs.Trigger value="map" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Map</Tabs.Trigger>
<Tabs.Trigger value="report" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Reports</Tabs.Trigger>
</Tabs.List>
<AnimatePresence mode="wait">
<motion.div
Expand Down Expand Up @@ -203,27 +204,37 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
<Tabs.Content value="user-management" className="mt-6">
<UserManagementForm form={form} />
</Tabs.Content>
<Tabs.Content value="map" className="mt-6">
<Tabs.Content value="report" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Map Provider</CardTitle>
<CardDescription>Choose the map provider to use in the application.</CardDescription>
<CardTitle>Report Generation</CardTitle>
<CardDescription>Generate and download a PDF report of your current analysis.</CardDescription>
</CardHeader>
<CardContent>
<RadioGroup
value={mapProvider}
onValueChange={(value) => setMapProvider(value as MapProvider)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapbox" id="mapbox" />
<Label htmlFor="mapbox">Mapbox</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="google" id="google" />
<Label htmlFor="google">Google Maps</Label>
</div>
</RadioGroup>
<CardContent className="space-y-4">
<div className="flex flex-col space-y-4">
<p className="text-sm text-muted-foreground">
Your report will include the conversation history, current map view, analysis results, and any drawn features or measurements.
</p>
<ReportButton inline={true} />
</div>

<div className="pt-6 border-t">
<h4 className="text-sm font-medium mb-4">Map Provider Settings</h4>
<RadioGroup
value={mapProvider}
onValueChange={(value) => setMapProvider(value as MapProvider)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mapbox" id="mapbox" />
<Label htmlFor="mapbox">Mapbox</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="google" id="google" />
<Label htmlFor="google">Google Maps</Label>
</div>
</RadioGroup>
</div>
</CardContent>
</Card>
</Tabs.Content>
Expand Down
169 changes: 169 additions & 0 deletions lib/utils/report-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { jsPDF } from 'jspdf';
import { AIMessage } from '@/lib/types';
import { MapData } from '@/components/map/map-data-context';
import markdownToTxt from 'markdown-to-txt';

export interface ReportData {
messages: AIMessage[];
drawnFeatures: MapData['drawnFeatures'];
mapSnapshot: string;
chatTitle: string;
}

export async function generateReport({
messages,
drawnFeatures,
mapSnapshot,
chatTitle
}: ReportData) {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 15;
const contentWidth = pageWidth - 2 * margin;
let yOffset = margin;

const checkPageBreak = (neededHeight: number) => {
if (yOffset + neededHeight > pageHeight - margin) {
doc.addPage();
yOffset = margin;
return true;
}
return false;
};

const addTextWithAutoPageBreak = (text: string, fontSize: number, style: 'normal' | 'bold' = 'normal', color: [number, number, number] = [0, 0, 0]) => {
doc.setFontSize(fontSize);
doc.setFont('helvetica', style);
doc.setTextColor(color[0], color[1], color[2]);

const lines: string[] = doc.splitTextToSize(text, contentWidth);
for (const line of lines) {
if (yOffset + 7 > pageHeight - margin) {
doc.addPage();
yOffset = margin;
}
doc.text(line, margin, yOffset);
yOffset += 7;
}
yOffset += 3; // Small gap after text blocks
};

// --- Cover Page ---
doc.setFontSize(24);
doc.setTextColor(40, 40, 40);
doc.text('QCX Analysis Report', margin, yOffset);
yOffset += 15;

doc.setFontSize(18);
doc.text(chatTitle || 'Untitled Chat', margin, yOffset);
yOffset += 10;

doc.setFontSize(12);
doc.setTextColor(100, 100, 100);
doc.text(`Generated on: ${new Date().toLocaleString()}`, margin, yOffset);
yOffset += 20;

if (mapSnapshot) {
try {
const imgHeight = (contentWidth * 9) / 16;
checkPageBreak(imgHeight);
doc.addImage(mapSnapshot, 'PNG', margin, yOffset, contentWidth, imgHeight);
yOffset += imgHeight + 20;
} catch (e) {
console.error('Error adding map snapshot to PDF:', e);
}
}

// --- Q&A Section ---
doc.setFontSize(16);
doc.setTextColor(40, 40, 40);
checkPageBreak(10);
doc.text('Conversation History', margin, yOffset);
yOffset += 10;

const userMessages = messages.filter(m => m.type === 'input' || m.type === 'input_related');

for (const userMsg of userMessages) {
let userContent = '';
try {
const json = JSON.parse(userMsg.content as string);
userContent = userMsg.type === 'input' ? json.input : json.related_query;
} catch (e) {
userContent = userMsg.content as string;
}
Comment on lines +90 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add validation for parsed JSON structure.

The parsed JSON object is not validated before accessing json.input or json.related_query. If the structure is unexpected, this could fail silently or throw runtime errors.

🛡️ Suggested fix with validation
     let userContent = '';
     try {
         const json = JSON.parse(userMsg.content as string);
-        userContent = userMsg.type === 'input' ? json.input : json.related_query;
+        userContent = userMsg.type === 'input' 
+          ? (json.input || 'No input content') 
+          : (json.related_query || 'No related query');
     } catch (e) {
         userContent = userMsg.content as string;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const json = JSON.parse(userMsg.content as string);
userContent = userMsg.type === 'input' ? json.input : json.related_query;
} catch (e) {
userContent = userMsg.content as string;
}
const json = JSON.parse(userMsg.content as string);
userContent = userMsg.type === 'input'
? (json.input || 'No input content')
: (json.related_query || 'No related query');
} catch (e) {
userContent = userMsg.content as string;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/report-generator.ts` around lines 90 - 94, The code parses
userMsg.content into json and directly reads json.input or json.related_query;
add validation to ensure the parsed value is an object and contains the expected
keys before using them. In the try block where JSON.parse(userMsg.content) is
assigned to json, verify typeof json === 'object' && json !== null and then use
checks like 'input' in json or 'related_query' in json (or optional chaining
with fallbacks) to set userContent safely; keep the existing catch fallback to
userMsg.content. Update the logic around userMsg, json, and userContent to avoid
runtime errors when the parsed structure is unexpected.


addTextWithAutoPageBreak(`User: ${userContent}`, 12, 'bold', [60, 60, 60]);

const userIdx = messages.indexOf(userMsg);
const nextUserIdx = messages.findIndex((m, i) => i > userIdx && (m.type === 'input' || m.type === 'input_related'));
const turnMessages = messages.slice(userIdx + 1, nextUserIdx === -1 ? undefined : nextUserIdx);

const aiResponse = turnMessages.find(m => m.type === 'response');
if (aiResponse) {
// Render markdown as plain text
const plainTextAI = markdownToTxt(aiResponse.content as string);
addTextWithAutoPageBreak(`QCX: ${plainTextAI}`, 12, 'normal', [80, 80, 80]);
}

const searchResult = turnMessages.find(m => m.type === 'resolution_search_result');
if (searchResult) {
try {
const data = JSON.parse(searchResult.content as string);

// GeoJSON Summary
if (data.summary) {
addTextWithAutoPageBreak(`Analysis Summary: ${data.summary}`, 11, 'normal', [80, 80, 80]);
}

const images = [data.mapboxImage, data.googleImage, data.image].filter(Boolean);
if (images.length > 0) {
const imgWidth = (contentWidth - 10) / 2;
const imgHeight = (imgWidth * 3) / 4;

checkPageBreak(imgHeight + 10);

for (let i = 0; i < Math.min(images.length, 2); i++) {
doc.addImage(images[i], 'JPEG', margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify image format before hardcoding 'JPEG'.

The code assumes all images are JPEG format, but the images from different sources (mapboxImage, googleImage, image) may be in different formats (PNG, WebP, etc.). jsPDF may fail or produce corrupted output if the format doesn't match.

Consider detecting the format from the data URL prefix or use a more flexible format like 'PNG' that supports transparency, or handle each image's actual format:

🔍 Suggested improvement
             for (let i = 0; i < Math.min(images.length, 2); i++) {
-                doc.addImage(images[i], 'JPEG', margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
+                // Detect format from data URL or default to JPEG
+                const format = images[i].startsWith('data:image/png') ? 'PNG' : 'JPEG';
+                doc.addImage(images[i], format, margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
doc.addImage(images[i], 'JPEG', margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
for (let i = 0; i < Math.min(images.length, 2); i++) {
// Detect format from data URL or default to JPEG
const format = images[i].startsWith('data:image/png') ? 'PNG' : 'JPEG';
doc.addImage(images[i], format, margin + (i * (imgWidth + 10)), yOffset, imgWidth, imgHeight);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/report-generator.ts` at line 127, The call to
doc.addImage(images[i], 'JPEG', ...) hardcodes JPEG but images (mapboxImage,
googleImage, image) may be PNG/WebP/etc.; update the image handling around the
images array and the doc.addImage call to detect each image's format from its
data URL prefix (inspect the "data:*/*;base64," mime type), map the mime type to
jsPDF's expected format string (e.g., 'PNG' for image/png, 'JPEG' for
image/jpeg, etc.), and pass that variable format into doc.addImage for each
image, with a sensible fallback (e.g., 'PNG') if the mime type is missing or
unsupported. Ensure detection logic is colocated with the images array
construction and used when calling doc.addImage so each image is added with the
correct format.

}
yOffset += imgHeight + 10;
}
} catch (e) {
console.error('Error parsing resolution search result for PDF:', e);
}
Comment on lines +112 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate parsed search result structure before accessing properties.

The code accesses data.summary, data.mapboxImage, data.googleImage, and data.image without validating that these properties exist in the parsed object. This could cause runtime errors if the structure is unexpected.

🛡️ Suggested fix with proper validation
       try {
         const data = JSON.parse(searchResult.content as string);
 
         // GeoJSON Summary
-        if (data.summary) {
+        if (data && typeof data === 'object' && data.summary) {
             addTextWithAutoPageBreak(`Analysis Summary: ${data.summary}`, 11, 'normal', [80, 80, 80]);
         }
 
-        const images = [data.mapboxImage, data.googleImage, data.image].filter(Boolean);
+        const images = data && typeof data === 'object' 
+          ? [data.mapboxImage, data.googleImage, data.image].filter(Boolean)
+          : [];
         if (images.length > 0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/report-generator.ts` around lines 112 - 133, Ensure the parsed JSON
from searchResult is an object and has the expected properties before using
them: after const data = JSON.parse(...), check that data is a non-null object
and that summary is a string before calling addTextWithAutoPageBreak, and verify
mapboxImage/googleImage/image are valid non-empty strings (or valid image
payloads) before adding them to the images array and calling doc.addImage; use
optional chaining/typeof checks and provide safe defaults, only call
checkPageBreak/doc.addImage when image entries pass validation, and keep error
handling around parsing/image rendering (refer to searchResult, data,
addTextWithAutoPageBreak, checkPageBreak, doc.addImage, and yOffset).

}

yOffset += 5;
}

// --- Drawings Appendix ---
if (drawnFeatures && drawnFeatures.length > 0) {
doc.addPage();
yOffset = margin;

doc.setFontSize(16);
doc.setTextColor(40, 40, 40);
doc.setFont('helvetica', 'bold');
doc.text('Drawings & Measurements', margin, yOffset);
yOffset += 15;

drawnFeatures.forEach((feature, index) => {
checkPageBreak(25);
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text(`${index + 1}. ${feature.type}`, margin, yOffset);
yOffset += 7;

doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Measurement: ${feature.measurement}`, margin + 5, yOffset);
yOffset += 7;

const coords = JSON.stringify(feature.geometry.coordinates).substring(0, 100) + '...';
doc.text(`Coordinates: ${coords}`, margin + 5, yOffset);
Comment on lines +162 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Improve coordinate truncation to avoid cutting mid-value.

The current implementation uses substring(0, 100) which can truncate in the middle of a coordinate pair or number, producing confusing output like [[-122.4, 37.7], [-122.3, 3....

♻️ Suggested improvement for cleaner truncation
-      const coords = JSON.stringify(feature.geometry.coordinates).substring(0, 100) + '...';
-      doc.text(`Coordinates: ${coords}`, margin + 5, yOffset);
+      // Show first few coordinate pairs cleanly
+      const coords = feature.geometry.coordinates;
+      const coordsArray = Array.isArray(coords[0]) ? coords.slice(0, 3) : [coords];
+      const coordsStr = JSON.stringify(coordsArray) + (coords.length > 3 ? '... (truncated)' : '');
+      doc.text(`Coordinates: ${coordsStr}`, margin + 5, yOffset);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const coords = JSON.stringify(feature.geometry.coordinates).substring(0, 100) + '...';
doc.text(`Coordinates: ${coords}`, margin + 5, yOffset);
// Show first few coordinate pairs cleanly
const coords = feature.geometry.coordinates;
const coordsArray = Array.isArray(coords[0]) ? coords.slice(0, 3) : [coords];
const coordsStr = JSON.stringify(coordsArray) + (coords.length > 3 ? '... (truncated)' : '');
doc.text(`Coordinates: ${coordsStr}`, margin + 5, yOffset);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/report-generator.ts` around lines 162 - 163, The coordinate string
is being naively truncated with substring(0, 100) which can cut a number or
coordinate pair mid-value; modify the logic that builds coords (using
feature.geometry.coordinates) to serialize coordinates element-by-element and
append whole coordinate entries until a character limit (e.g., 100) would be
exceeded, then append an ellipsis, and use that result in the existing doc.text
call (the variable coords and the doc.text(`Coordinates: ${coords}`, margin + 5,
yOffset) should remain but receive the safely truncated string).

yOffset += 10;
});
}

doc.save(`${chatTitle || 'QCX-Report'}.pdf`);
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tavily/core": "^0.6.4",
"@turf/turf": "^7.2.0",
"@types/jspdf": "^2.0.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check jspdf package versions and compatibility

# Check latest available version of jspdf
echo "=== Latest jspdf version ==="
npm view jspdf version

# Check if version 4.2.1 exists
echo -e "\n=== Checking if jspdf@4.2.1 exists ==="
npm view jspdf@4.2.1 version 2>&1 || echo "Version 4.2.1 not found"

# Check available versions of jspdf
echo -e "\n=== Recent jspdf versions ==="
npm view jspdf versions --json | jq '.[-10:]'

# Check latest `@types/jspdf` version
echo -e "\n=== Latest `@types/jspdf` version ==="
npm view `@types/jspdf` version

# Check for security advisories
echo -e "\n=== Security advisories for jspdf ==="
npm audit --package-lock-only --json | jq '.vulnerabilities.jspdf // "No vulnerabilities found"'

Repository: QueueLab/QCX

Length of output: 757


Verify @types/jspdf type coverage for jsPDF 4.2.1.

jsPDF 4.2.1 is the current stable version, not an outdated pre-release. However, @types/jspdf@2.0.0 (the latest available types) may lack type definitions for features introduced in jsPDF 3.x and 4.x. Check that your codebase only uses types from the 2.x definitions or consider if a newer types package is available with proper 4.x support.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 51, The dependency entry "@types/jspdf" may not match
the jsPDF runtime (4.2.1); audit all places using jsPDF types (search for jsPDF,
JsPDF, and relevant constructors/methods) to verify they are covered by
`@types/jspdf`@2.0.0, and then either replace/update the type source: remove
`@types/jspdf` if jsPDF 4.x includes its own types, upgrade to a
community/official types package that lists 4.x support, or add missing type
declarations (ambient module augmentations or specific d.ts shims) for any
methods/classes used but missing in `@types/jspdf`; ensure package.json reflects
the chosen approach and run TypeScript type-check to confirm no new errors.

"@types/mapbox__mapbox-gl-draw": "^1.4.8",
"@types/pg": "^8.15.4",
"@upstash/redis": "^1.35.0",
Expand All @@ -68,11 +69,13 @@
"framer-motion": "^12.23.24",
"geotiff": "^2.1.4-beta.1",
"glassmorphic": "^0.0.3",
"jspdf": "^4.2.1",
"katex": "^0.16.22",
"lodash": "^4.17.21",
"lottie-react": "^2.4.1",
"lucide-react": "^0.507.0",
"mapbox-gl": "^3.11.0",
"markdown-to-txt": "^2.0.1",
"next": "15.3.8",
"next-themes": "^0.3.0",
"open-codex": "^0.1.30",
Expand Down