From 1ca140d9ad2184138fc45d4146946f939a3b740d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:18:46 +0000 Subject: [PATCH 1/5] feat: implement PDF report generation - Added `jspdf` and `markdown-to-txt` dependencies. - Created `lib/utils/report-generator.ts` for PDF generation logic. - Created `components/report-button.tsx` as a UI entry point. - Integrated `ReportButton` into the desktop header. - Implemented robust pagination and markdown-to-text conversion for AI responses in the report. - Added drawings appendix to the report. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- bun.lock | 60 +++++++++++- components/header.tsx | 3 + components/report-button.tsx | 60 ++++++++++++ lib/utils/report-generator.ts | 169 ++++++++++++++++++++++++++++++++++ package.json | 4 + 5 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 components/report-button.tsx create mode 100644 lib/utils/report-generator.ts diff --git a/bun.lock b/bun.lock index f101e5d7..74fe36e9 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,7 @@ "@tailwindcss/typography": "^0.5.16", "@tavily/core": "^0.6.4", "@turf/turf": "^7.2.0", + "@types/jspdf": "^2.0.0", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", "@upstash/redis": "^1.35.0", @@ -56,11 +57,14 @@ "framer-motion": "^12.23.24", "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", + "html2canvas": "^1.4.1", + "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", @@ -199,7 +203,7 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], - "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], @@ -941,6 +945,8 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@types/jspdf": ["@types/jspdf@2.0.0", "", { "dependencies": { "jspdf": "*" } }, "sha512-oonYDXI4GegGaG7FFVtriJ+Yqlh4YR3L3NVDiwCEBVG7sbya19SoGx4MW4kg1MCMRPgkbbFTck8YKJL8PrkDfA=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], @@ -959,12 +965,16 @@ "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/pbf": ["@types/pbf@3.0.5", "", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -977,6 +987,8 @@ "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/tz-lookup": ["@types/tz-lookup@6.1.2", "", {}, "sha512-9y31Xf/8FHXrCHjvVjGZLcsayAa6ABNc8bZlk6MPOQLLlr41tICSqW3TRPRIx2nodbzdKs5N7ipHWBrUsWUiAA=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -1131,6 +1143,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -1169,6 +1183,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="], + "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -1237,6 +1253,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], @@ -1245,6 +1263,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "csscolorparser": ["csscolorparser@1.0.3", "", {}, "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -1309,6 +1329,8 @@ "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], @@ -1433,6 +1455,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], @@ -1585,6 +1609,8 @@ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -1617,6 +1643,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-absolute-url": ["is-absolute-url@4.0.1", "", {}, "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A=="], @@ -1739,6 +1767,8 @@ "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], + "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], + "jsts": ["jsts@2.7.1", "", {}, "sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -1773,8 +1803,12 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.escape": ["lodash.escape@4.0.1", "", {}, "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.unescape": ["lodash.unescape@4.0.1", "", {}, "sha512-DhhGRshNS1aX6s5YdBE3njCCouPgnG29ebyHvImlZzXZf2SHgt+J08DHgytTPnpywNbO1Y8mNUFyQuIDBq2JZg=="], + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1795,7 +1829,9 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "markdown-to-txt": ["markdown-to-txt@2.0.1", "", { "dependencies": { "lodash.escape": "^4.0.1", "lodash.unescape": "^4.0.1", "marked": "^4.0.14" } }, "sha512-Hsj7KTN8k1gutlLum3vosHwVZGnv8/cbYKWVkUyo/D1rzOYddbDesILebRfOsaVfjIBJank/AVOySBlHAYqfZw=="], + + "marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], @@ -2021,6 +2057,8 @@ "pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="], + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], @@ -2113,6 +2151,8 @@ "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], + "ramda": ["ramda@0.29.1", "", {}, "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2153,6 +2193,8 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "rehype-external-links": ["rehype-external-links@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-is-element": "^3.0.0", "is-absolute-url": "^4.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw=="], @@ -2189,6 +2231,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "robust-predicates": ["robust-predicates@2.0.4", "", {}, "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg=="], @@ -2275,6 +2319,8 @@ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -2335,6 +2381,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], + "sweepline-intersections": ["sweepline-intersections@1.5.0", "", { "dependencies": { "tinyqueue": "^2.0.0" } }, "sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ=="], "swr": ["swr@2.3.8", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w=="], @@ -2349,6 +2397,8 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], @@ -2465,6 +2515,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -2723,6 +2775,8 @@ "open-codex/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "open-codex/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "open-codex/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], "open-codex/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -2745,6 +2799,8 @@ "react-reconciler/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "react-textarea-autosize/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/components/header.tsx b/components/header.tsx index d354120a..26f70f18 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react' import { MapToggle } from './map-toggle' import { ProfileToggle } from './profile-toggle' +import { ReportButton } from './report-button' import { PurchaseCreditsPopup } from './purchase-credits-popup' import { useUsageToggle } from './usage-toggle-context' import { useProfileToggle } from './profile-toggle-context' @@ -70,6 +71,8 @@ export const Header = () => { + + + ) +} diff --git a/lib/utils/report-generator.ts b/lib/utils/report-generator.ts new file mode 100644 index 00000000..cfde829a --- /dev/null +++ b/lib/utils/report-generator.ts @@ -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; + } + + 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); + } + yOffset += imgHeight + 10; + } + } catch (e) { + console.error('Error parsing resolution search result for PDF:', e); + } + } + + 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); + yOffset += 10; + }); + } + + doc.save(`${chatTitle || 'QCX-Report'}.pdf`); +} diff --git a/package.json b/package.json index 87066925..b178a9c6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@tailwindcss/typography": "^0.5.16", "@tavily/core": "^0.6.4", "@turf/turf": "^7.2.0", + "@types/jspdf": "^2.0.0", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", "@upstash/redis": "^1.35.0", @@ -68,11 +69,14 @@ "framer-motion": "^12.23.24", "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", + "html2canvas": "^1.4.1", + "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", From 5517ab6b716ee4054516ef8cfd1ee8ba80aeefe0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:38:45 +0000 Subject: [PATCH 2/5] feat: implement robust PDF report generation - Implemented `generateReport` utility with `jsPDF` for comprehensive PDF export. - Added automatic page breaking and markdown-to-text conversion for long AI responses. - Included map snapshots, analysis summaries, and a drawings appendix. - Created `ReportButton` component using React Portals to ensure safe rendering in the global Header. - Verified successful production build after addressing SSR/prerendering constraints. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- bun.lock | 1 - components/chat.tsx | 3 +++ components/header.tsx | 3 +-- components/report-button.tsx | 16 ++++++++++++---- package.json | 1 - 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 74fe36e9..bca9d9e3 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,6 @@ "framer-motion": "^12.23.24", "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", - "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "katex": "^0.16.22", "lodash": "^4.17.21", diff --git a/components/chat.tsx b/components/chat.tsx index d3560a77..7f019a46 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 @@ -139,6 +140,7 @@ export function Chat({ id }: ChatProps) { return ( {/* Add Provider */} +
{activeView ? : isUsageOpen ? : } @@ -185,6 +187,7 @@ export function Chat({ id }: ChatProps) { return ( {/* Add Provider */} +
{/* This is the new div for scrolling */}
diff --git a/components/header.tsx b/components/header.tsx index 26f70f18..45db5f99 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -15,7 +15,6 @@ import { } from 'lucide-react' import { MapToggle } from './map-toggle' import { ProfileToggle } from './profile-toggle' -import { ReportButton } from './report-button' import { PurchaseCreditsPopup } from './purchase-credits-popup' import { useUsageToggle } from './usage-toggle-context' import { useProfileToggle } from './profile-toggle-context' @@ -72,7 +71,7 @@ export const Header = () => { - +
) + + if (!portalNode) return null + + return createPortal(button, portalNode) } diff --git a/package.json b/package.json index b178a9c6..bb02e298 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "framer-motion": "^12.23.24", "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", - "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "katex": "^0.16.22", "lodash": "^4.17.21", From e1b6ff0f6d1f6d7ae89a069b56372b2ab9b93441 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:19:31 +0000 Subject: [PATCH 3/5] feat: integrate report generation into Settings view - Renamed 'Map' tab to 'Reports' in Settings. - Added 'Report Generation' section to the Reports tab. - Updated `ReportButton` to support an inline display mode with a label. - Moved map provider settings into the new Reports tab. - Maintained the existing 'Download Report' button in the header via portals. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/report-button.tsx | 20 ++++++--- components/settings/components/settings.tsx | 49 +++++++++++++-------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/components/report-button.tsx b/components/report-button.tsx index 14faf929..086ab92e 100644 --- a/components/report-button.tsx +++ b/components/report-button.tsx @@ -10,7 +10,11 @@ import { useMap } from '@/components/map/map-context' import { generateReport } from '@/lib/utils/report-generator' import { toast } from 'sonner' -export const ReportButton = () => { +interface ReportButtonProps { + inline?: boolean +} + +export const ReportButton = ({ inline = false }: ReportButtonProps) => { const [aiState] = useAIState() const { mapData } = useMapData() const { map } = useMap() @@ -18,8 +22,10 @@ export const ReportButton = () => { const [portalNode, setPortalNode] = useState(null) useEffect(() => { - setPortalNode(document.getElementById('header-report-portal')) - }, []) + if (!inline) { + setPortalNode(document.getElementById('header-report-portal')) + } + }, [inline]) const handleDownloadReport = async () => { if (isGenerating) return @@ -48,20 +54,24 @@ export const ReportButton = () => { const button = ( ) + if (inline) return button + if (!portalNode) return null return createPortal(button, portalNode) diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index 8806664d..d7f8c037 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -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({ @@ -166,7 +167,7 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { System Prompt Model Selection User Management - Map + Reports - + - Map Provider - Choose the map provider to use in the application. + Report Generation + Generate and download a PDF report of your current analysis. - - setMapProvider(value as MapProvider)} - className="space-y-2" - > -
- - -
-
- - -
-
+ +
+

+ Your report will include the conversation history, current map view, analysis results, and any drawn features or measurements. +

+ +
+ +
+

Map Provider Settings

+ setMapProvider(value as MapProvider)} + className="space-y-2" + > +
+ + +
+
+ + +
+
+
From e0cda690c610b5f880860ef420fbdf02cc57c1cb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 08:37:38 +0000 Subject: [PATCH 4/5] fix: remove report icon from header and disable success notification - Removed `ReportButton` from the header. - Disabled success toast notification after successful report generation. - Cleaned up unused portal target for the report button in the header. - Kept the feature accessible via the 'Reports' tab in Settings. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/chat.tsx | 2 -- components/header.tsx | 2 -- components/report-button.tsx | 20 +++----------------- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index 7f019a46..8b36f89f 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -140,7 +140,6 @@ export function Chat({ id }: ChatProps) { return ( {/* Add Provider */} -
{activeView ? : isUsageOpen ? : } @@ -187,7 +186,6 @@ export function Chat({ id }: ChatProps) { return ( {/* Add Provider */} -
{/* This is the new div for scrolling */}
diff --git a/components/header.tsx b/components/header.tsx index 45db5f99..d354120a 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -70,8 +70,6 @@ export const Header = () => { - -
) - - if (inline) return button - - if (!portalNode) return null - - return createPortal(button, portalNode) } From 9e686ad4d327176e7e0bfe5d56ce7112ff33d8b3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:45:41 +0000 Subject: [PATCH 5/5] chore: merge main into feature branch - Merged the latest changes from the main branch. - Verified that PDF report generation functionality is preserved. - Confirmed successful production build. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- .env.local.example | 4 + GEMINI_3.1_PRO_INTEGRATION.md | 8 +- app/actions.tsx | 24 ++++- lib/agents/resolution-search.tsx | 39 +------ lib/agents/tools/geospatial.tsx | 27 +++-- lib/agents/tools/index.tsx | 11 +- lib/agents/tools/search.tsx | 36 ++----- lib/schema/geospatial.tsx | 174 ++++++++++--------------------- lib/schema/resolution-search.ts | 41 ++++++++ lib/utils/index.ts | 24 ++--- 10 files changed, 181 insertions(+), 207 deletions(-) create mode 100644 lib/schema/resolution-search.ts diff --git a/.env.local.example b/.env.local.example index e52eec0c..e116efa3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -20,6 +20,10 @@ NEXT_PUBLIC_COMPOSIO_USER_ID=user@example.com # Gemini 3.1 Pro (Google Generative AI) GEMINI_3_PRO_API_KEY=your_gemini_3_pro_api_key_here +# Search API (https://tavily.com) +# If not set, the `search` tool is skipped and the LLM answers from its own knowledge. +TAVILY_API_KEY=your_tavily_api_key + # Supabase Credentials NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL_HERE NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY_HERE diff --git a/GEMINI_3.1_PRO_INTEGRATION.md b/GEMINI_3.1_PRO_INTEGRATION.md index 08f51ab4..697c5f62 100644 --- a/GEMINI_3.1_PRO_INTEGRATION.md +++ b/GEMINI_3.1_PRO_INTEGRATION.md @@ -10,8 +10,8 @@ This document describes the integration of Google's Gemini 3.1 Pro model into th Added Gemini 3.1 Pro as a provider option in the `getModel()` function with the following priority order: -1. **Gemini 3.1 Pro** - Primary choice if `GEMINI_3_PRO_API_KEY` is configured *(UPDATED PRIORITY)* -2. **xAI (Grok)** - Secondary choice if `XAI_API_KEY` is configured +1. **xAI (Grok)** - Primary choice if `XAI_API_KEY` is configured *(UPDATED PRIORITY)* +2. **Gemini 3.1 Pro** - Secondary choice if `GEMINI_3_PRO_API_KEY` is configured 3. **AWS Bedrock** - Tertiary choice if AWS credentials are configured 4. **OpenAI** - Default fallback if `OPENAI_API_KEY` is configured @@ -59,10 +59,10 @@ Gemini 3.1 Pro (`gemini-3.1-pro-preview`) supports: The provider selection follows this priority order: ``` -GEMINI_3_PRO_API_KEY exists? → Use Gemini 3.1 Pro - ↓ No XAI_API_KEY exists? → Use Grok ↓ No +GEMINI_3_PRO_API_KEY exists? → Use Gemini 3.1 Pro + ↓ No AWS credentials exist? → Use AWS Bedrock ↓ No OPENAI_API_KEY exists? → Use OpenAI (default) diff --git a/app/actions.tsx b/app/actions.tsx index 50e985bf..167d23d4 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -114,11 +114,30 @@ async function submit(formData?: FormData, skip?: boolean) { const analysisResult = await streamResult.object; summaryStream.done(analysisResult.summary || 'Analysis complete.'); - if (analysisResult.geoJson) { + // Reconstruct standard GeoJSON from flattened schema if present + let geoJson: FeatureCollection | null = null; + if (analysisResult.geoJson && analysisResult.geoJson.features) { + geoJson = { + type: 'FeatureCollection', + features: analysisResult.geoJson.features.map(f => ({ + type: 'Feature', + geometry: { + type: f.geometryType as any, + coordinates: f.coordinates as any + }, + properties: { + name: f.name, + description: f.description + } + })) + }; + } + + if (geoJson) { uiStream.append( ); } @@ -171,6 +190,7 @@ async function submit(formData?: FormData, skip?: boolean) { role: 'assistant', content: JSON.stringify({ ...analysisResult, + geoJson: geoJson, // Use reconstructed GeoJSON for storage/UI image: dataUrl, mapboxImage: mapboxDataUrl, googleImage: googleDataUrl diff --git a/lib/agents/resolution-search.tsx b/lib/agents/resolution-search.tsx index d456fe49..9985712d 100644 --- a/lib/agents/resolution-search.tsx +++ b/lib/agents/resolution-search.tsx @@ -1,45 +1,10 @@ import { CoreMessage, streamObject } from 'ai' import { getModel } from '@/lib/utils' -import { z } from 'zod' import { tavily } from '@tavily/core' +import { resolutionSearchSchema } from '@/lib/schema/resolution-search' // This agent is now a pure data-processing module, with no UI dependencies. -// Define the schema for the structured response from the AI. -const resolutionSearchSchema = z.object({ - summary: z.string().describe('A detailed text summary of the analysis, including land feature classification, points of interest, relevant current news, and temporal context.'), - geoJson: z.object({ - type: z.literal('FeatureCollection'), - features: z.array(z.object({ - type: z.literal('Feature'), - geometry: z.object({ - type: z.string(), // e.g., 'Point', 'Polygon' - coordinates: z.any(), - }), - properties: z.object({ - name: z.string(), - description: z.string().optional(), - }), - })), - }).describe('A GeoJSON object containing points of interest and classified land features to be overlaid on the map.'), - extractedCoordinates: z.object({ - latitude: z.number(), - longitude: z.number() - }).optional().describe('The extracted geocoordinates of the center of the image.'), - cogInfo: z.object({ - applicable: z.boolean(), - description: z.string().optional() - }).optional().describe('Information about whether Cloud Optimized GeoTIFF (COG) data is applicable or available for this area.'), - newsContext: z.object({ - hasRecentNews: z.boolean(), - newsItems: z.array(z.object({ - title: z.string(), - summary: z.string(), - relevance: z.string() - })).optional() - }).optional().describe('Recent news and events relevant to the analyzed location.') -}) - export interface DrawnFeature { id: string; type: 'Polygon' | 'LineString'; @@ -162,7 +127,7 @@ Use these user-drawn areas/lines as primary areas of interest for your analysis. 4. **Coordinate Extraction:** If possible, confirm or refine the geocoordinates (latitude/longitude) of the center of the image. 5. **COG Applicability:** Determine if this location would benefit from Cloud Optimized GeoTIFF (COG) analysis for high-precision temporal or spectral data. 6. **News Integration:** Reference any recent news or events that may be relevant to the current state of the location. -7. **Structured Output:** Return your findings in a structured JSON format including summary, geoJson, and newsContext. +7. **Structured Output:** Return your findings in a structured JSON format including summary, geoJson (if any), news context, and any extracted coordinates or COG information. Use the provided schema. Your analysis should be based on the visual information in the image, the temporal context provided, and your general knowledge. Do not attempt to access external websites or perform web searches beyond what has been provided. diff --git a/lib/agents/tools/geospatial.tsx b/lib/agents/tools/geospatial.tsx index 33099975..1690f94d 100644 --- a/lib/agents/tools/geospatial.tsx +++ b/lib/agents/tools/geospatial.tsx @@ -346,12 +346,27 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g // Build arguments const toolArgs = (() => { switch (queryType) { - case 'directions': return { waypoints: [params.origin, params.destination], includeMapPreview: includeMap, profile: params.mode }; - case 'distance': return { places: [params.origin, params.destination], includeMapPreview: includeMap, mode: params.mode || 'driving' }; - case 'reverse': return { searchText: `${params.coordinates.latitude},${params.coordinates.longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 }; - case 'search': return { searchText: params.query, includeMapPreview: includeMap, maxResults: params.maxResults || 5, ...(params.coordinates && { proximity: `${params.coordinates.latitude},${params.coordinates.longitude}` }), ...(params.radius && { radius: params.radius }) }; - case 'geocode': - case 'map': return { searchText: params.location, includeMapPreview: includeMap, maxResults: queryType === 'geocode' ? params.maxResults || 5 : undefined }; + case 'directions': { + if (!params.origin || !params.destination) throw new Error("'directions' query requires origin and destination"); + return { waypoints: [params.origin, params.destination], includeMapPreview: includeMap, profile: params.mode }; + } + case 'distance': { + if (!params.origin || !params.destination) throw new Error("'distance' query requires origin and destination"); + return { places: [params.origin, params.destination], includeMapPreview: includeMap, mode: params.mode || 'driving' }; + } + case 'reverse': { + if (!params.coordinates) throw new Error("'reverse' query requires coordinates"); + return { searchText: `${params.coordinates.latitude},${params.coordinates.longitude}`, includeMapPreview: includeMap, maxResults: params.maxResults || 5 }; + } + case 'search': { + if (!params.query) throw new Error("'search' query requires query"); + return { searchText: params.query, includeMapPreview: includeMap, maxResults: params.maxResults || 5, ...(params.coordinates && { proximity: `${params.coordinates.latitude},${params.coordinates.longitude}` }), ...(params.radius && { radius: params.radius }) }; + } + case 'geocode': + case 'map': { + if (!params.location) throw new Error(`'${queryType}' query requires location`); + return { searchText: params.location, includeMapPreview: includeMap, maxResults: queryType === 'geocode' ? params.maxResults || 5 : undefined }; + } } })(); diff --git a/lib/agents/tools/index.tsx b/lib/agents/tools/index.tsx index 4c22b887..2491cf9c 100644 --- a/lib/agents/tools/index.tsx +++ b/lib/agents/tools/index.tsx @@ -14,10 +14,6 @@ export interface ToolProps { export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => { const tools: any = { - search: searchTool({ - uiStream, - fullResponse - }), retrieve: retrieveTool({ uiStream, fullResponse @@ -28,6 +24,13 @@ export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => }) } + if (process.env.TAVILY_API_KEY) { + tools.search = searchTool({ + uiStream, + fullResponse + }) + } + if (process.env.SERPER_API_KEY) { tools.videoSearch = videoSearchTool({ uiStream, diff --git a/lib/agents/tools/search.tsx b/lib/agents/tools/search.tsx index 7510a795..d9518b6e 100644 --- a/lib/agents/tools/search.tsx +++ b/lib/agents/tools/search.tsx @@ -1,5 +1,4 @@ import { createStreamableValue } from 'ai/rsc' -import Exa from 'exa-js' import { tavily } from '@tavily/core' import { searchSchema } from '@/lib/schema/search' import { Card } from '@/components/ui/card' @@ -39,22 +38,18 @@ export const searchTool = ({ uiStream, fullResponse }: ToolProps) => ({ const filledQuery = query.length < 5 ? query + ' '.repeat(5 - query.length) : query let searchResult - const searchAPI: 'tavily' | 'exa' = 'tavily' try { - searchResult = - searchAPI === 'tavily' - ? await tavilySearch( - filledQuery, - max_results, - search_depth, - include_answer, - topic, - time_range, - include_images, - include_image_descriptions, - include_raw_content - ) - : await exaSearch(query) + searchResult = await tavilySearch( + filledQuery, + max_results, + search_depth, + include_answer, + topic, + time_range, + include_images, + include_image_descriptions, + include_raw_content + ) } catch (error) { console.error('Search API error:', error) hasError = true @@ -101,12 +96,3 @@ async function tavilySearch( return { ...response, results: response.results.reverse() } } - -async function exaSearch(query: string, maxResults: number = 10): Promise { - const apiKey = process.env.EXA_API_KEY - const exa = new Exa(apiKey) - return exa.searchAndContents(query, { - highlights: true, - numResults: maxResults - }) -} diff --git a/lib/schema/geospatial.tsx b/lib/schema/geospatial.tsx index 98059711..c58b7f0a 100644 --- a/lib/schema/geospatial.tsx +++ b/lib/schema/geospatial.tsx @@ -1,124 +1,64 @@ import { z } from 'zod'; -// Improved schema using discriminatedUnion for better type safety and conditional requirements -// - Enforces required fields based on queryType (e.g., destination for directions/distance) -// - Renames 'query' to 'location' for clarity in most cases, but uses 'origin' and 'destination' for directions/distance -// - Makes 'coordinates' required for 'reverse' and optional for 'search' (as proximity) -// - Adds 'mode' for directions (e.g., driving, walking) assuming tool support can be added -// - Integrates 'radius' and 'maxResults' for 'search', assuming future tool arg expansion -// - Keeps 'includeMap' consistent across all -// - Defaults queryType removed; now required to encourage explicit typing -// - For 'map', treats as general query similar to geocode/search +// Flat schema. JSON Schema output is a single object with optional fields, +// so OpenAI-compatible endpoints (xAI) accept it. Field requirements per +// queryType are conveyed to the LLM via the queryType description; runtime +// behavior in the tool's execute() already tolerates missing fields per +// queryType, so loosening the schema introduces no new failure modes. -export const geospatialQuerySchema = z.discriminatedUnion('queryType', [ - z.object({ - queryType: z.literal('search'), - query: z.string() - .min(1, "Query cannot be empty") - .describe("Search term for places/POIs"), - coordinates: z.object({ - latitude: z.number().min(-90).max(90), - longitude: z.number().min(-180).max(180) - }) - .optional() - .describe("Optional reference point for proximity search"), - radius: z.number() - .positive() - .optional() - .describe("Search radius in kilometers"), - maxResults: z.number() - .int() - .positive() - .max(20) - .optional() - .default(5) - .describe("Maximum number of results to return"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), - }), - z.object({ - queryType: z.literal('geocode'), - location: z.string() - .min(1, "Location cannot be empty") - .describe("The location to geocode - address, place name, or landmark"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), - maxResults: z.number() - .int() - .positive() - .max(20) - .optional() - .default(5) - .describe("Maximum number of results to return"), - }), - z.object({ - queryType: z.literal('reverse'), - coordinates: z.object({ - latitude: z.number().min(-90).max(90), - longitude: z.number().min(-180).max(180) - }) - .describe("Coordinates for reverse geocoding"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), - maxResults: z.number() - .int() - .positive() - .max(20) - .optional() - .default(5) - .describe("Maximum number of results to return"), - }), - z.object({ - queryType: z.literal('directions'), - origin: z.string() - .min(1, "Origin cannot be empty") - .describe("Starting location for directions"), - destination: z.string() - .min(1, "Destination cannot be empty") - .describe("Ending location for directions"), - mode: z.enum(['driving', 'walking', 'cycling', 'transit']) - .optional() - .default('driving') - .describe("Transportation mode for directions"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), - }), - z.object({ - queryType: z.literal('distance'), - origin: z.string() - .min(1, "Origin cannot be empty") - .describe("Starting location for distance calculation"), - destination: z.string() - .min(1, "Destination cannot be empty") - .describe("Ending location for distance calculation"), - mode: z.enum(['driving', 'walking', 'cycling', 'transit']) - .optional() - .default('driving') - .describe("Transportation mode for distance"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), - }), - z.object({ - queryType: z.literal('map'), - location: z.string() - .min(1, "Location cannot be empty") - .describe("Location or area for map request"), - includeMap: z.boolean() - .optional() - .default(true) - .describe("Whether to include a map preview/URL in the response"), +export const geospatialQuerySchema = z.object({ + queryType: z.enum(['search', 'geocode', 'reverse', 'directions', 'distance', 'map']) + .describe( + "Type of geospatial query. Set the corresponding fields: " + + "'search' → query (optionally coordinates, radius, maxResults); " + + "'geocode' → location (optionally maxResults); " + + "'reverse' → coordinates (optionally maxResults); " + + "'directions' → origin + destination (optionally mode); " + + "'distance' → origin + destination (optionally mode); " + + "'map' → location." + ), + query: z.string() + .min(1) + .optional() + .describe("Search term for places/POIs (used by 'search')"), + location: z.string() + .min(1) + .optional() + .describe("Location to geocode or render as a map (used by 'geocode' and 'map')"), + coordinates: z.object({ + latitude: z.number().min(-90).max(90), + longitude: z.number().min(-180).max(180) }) -]); + .optional() + .describe("Coordinates (required for 'reverse', optional proximity hint for 'search')"), + origin: z.string() + .min(1) + .optional() + .describe("Starting location (used by 'directions' and 'distance')"), + destination: z.string() + .min(1) + .optional() + .describe("Ending location (used by 'directions' and 'distance')"), + mode: z.enum(['driving', 'walking', 'cycling', 'transit']) + .optional() + .default('driving') + .describe("Transportation mode (used by 'directions' and 'distance')"), + radius: z.number() + .positive() + .optional() + .describe("Search radius in kilometers (used by 'search')"), + maxResults: z.number() + .int() + .positive() + .max(20) + .optional() + .default(5) + .describe("Maximum number of results to return"), + includeMap: z.boolean() + .optional() + .default(true) + .describe("Whether to include a map preview/URL in the response"), +}); export type GeospatialQuery = z.infer; diff --git a/lib/schema/resolution-search.ts b/lib/schema/resolution-search.ts new file mode 100644 index 00000000..e7908abd --- /dev/null +++ b/lib/schema/resolution-search.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' + +// This schema is designed to be compatible with xAI's OpenAI-compatible endpoint. +// We use a flattened structure and avoid z.literal (which generates JSON Schema 'const') +// and z.any() to ensure maximum compatibility with xAI's schema validation. +// This follows the pattern established in lib/schema/geospatial.tsx. + +export const resolutionSearchSchema = z.object({ + summary: z.string().describe('A detailed text summary of the analysis, including land feature classification, points of interest, relevant current news, and temporal context.'), + + // geoJson is optional so the model is not forced to produce features when none are found. + geoJson: z.object({ + type: z.string().describe("Must be 'FeatureCollection'"), + features: z.array(z.object({ + type: z.string().describe("Must be 'Feature'"), + geometryType: z.string().describe("The type of geometry, e.g., 'Point', 'Polygon'"), + coordinates: z.array(z.number()) + .or(z.array(z.array(z.number()))) + .or(z.array(z.array(z.array(z.number())))) + .describe('Coordinates for the geometry'), + name: z.string().describe('Name of the feature or point of interest'), + description: z.string().optional().describe('Description of the feature') + })) + }).optional().describe('A collection of points of interest and classified land features to be overlaid on the map.'), + + // Flattened top-level fields for better xAI compatibility + extractedLatitude: z.number().optional().describe('The extracted latitude of the center of the image.'), + extractedLongitude: z.number().optional().describe('The extracted longitude of the center of the image.'), + + cogApplicable: z.boolean().optional().describe('Whether Cloud Optimized GeoTIFF (COG) data is applicable for this area.'), + cogDescription: z.string().optional().describe('Description of COG data availability or benefits.'), + + hasRecentNews: z.boolean().optional().describe('Whether there is recent news relevant to the location.'), + newsItems: z.array(z.object({ + title: z.string(), + summary: z.string(), + relevance: z.string() + })).optional().describe('List of recent news items relevant to the location.') +}) + +export type ResolutionSearch = z.infer; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 15565281..b2f6d47f 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -79,18 +79,7 @@ export async function getModel(requireVision: boolean = false) { } } - // Default behavior: Gemini -> Grok -> Bedrock -> OpenAI - if (gemini3ProApiKey) { - const google = createGoogleGenerativeAI({ - apiKey: gemini3ProApiKey, - }); - try { - return google('gemini-3.1-pro-preview'); - } catch (error) { - console.warn('Gemini 3.1 Pro API unavailable, falling back to next provider:', error); - } - } - + // Default behavior: Grok -> Gemini -> Bedrock -> OpenAI if (xaiApiKey) { const xai = createXai({ apiKey: xaiApiKey, @@ -103,6 +92,17 @@ export async function getModel(requireVision: boolean = false) { } } + if (gemini3ProApiKey) { + const google = createGoogleGenerativeAI({ + apiKey: gemini3ProApiKey, + }); + try { + return google('gemini-3.1-pro-preview'); + } catch (error) { + console.warn('Gemini 3.1 Pro API unavailable, falling back to next provider:', error); + } + } + if (awsAccessKeyId && awsSecretAccessKey) { const bedrock = createAmazonBedrock({ bedrockOptions: {