diff --git a/src/app/dashboard/chat/answer-pdf.test.ts b/src/app/dashboard/chat/answer-pdf.test.ts deleted file mode 100644 index e4fc84f..0000000 --- a/src/app/dashboard/chat/answer-pdf.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { slicePages, slugify } from "./answer-pdf"; - -describe("slicePages", () => { - it("returns a single page break (0) when everything fits", () => { - expect(slicePages([0, 100], [100, 50], 1000)).toEqual([0]); - }); - - it("breaks before a block that would overflow the page, never inside one", () => { - // block0: 0-100, block1: 100-300 (height 200), block2: 300-350 - const breaks = slicePages([0, 100, 300], [100, 200, 50], 250); - expect(breaks).toEqual([0, 100]); - }); - - it("gives an oversized single block its own page rather than splitting it", () => { - const breaks = slicePages([0, 500], [500, 50], 250); - // block0 alone is already 500 > 250 — no earlier content to break - // off from, so it stays on page 1; block1 doesn't fit alongside it. - expect(breaks).toEqual([0, 500]); - }); - - it("packs as many consecutive blocks as fit per page", () => { - const tops = [0, 50, 100, 150, 200]; - const heights = [50, 50, 50, 50, 50]; // bottoms: 50,100,150,200,250 - const breaks = slicePages(tops, heights, 120); - // Page1: 0-100 (blocks 0,1 = 100 <=120); block2 (bottom150) would make 150>120 -> break at 100 - // Page2 starts at 100: blocks2,3 -> bottom 200, 200-100=100<=120; block4 bottom250-100=150>120 -> break at 200 - expect(breaks).toEqual([0, 100, 200]); - }); - - it("returns [0] for no blocks", () => { - expect(slicePages([], [], 1000)).toEqual([0]); - }); -}); - -describe("slugify", () => { - it("lowercases and hyphenates", () => { - expect(slugify("The Hills Fuel Report")).toBe("the-hills-fuel-report"); - }); - - it("strips accents and punctuation", () => { - expect(slugify("André's Café — Q3!")).toBe("andre-s-cafe-q3"); - }); - - it("falls back to 'answer' for an empty/unusable title", () => { - expect(slugify(" ")).toBe("answer"); - }); - - it("caps length at 60 characters", () => { - const long = "a".repeat(200); - expect(slugify(long).length).toBeLessThanOrEqual(60); - }); -}); diff --git a/src/app/dashboard/chat/answer-pdf.ts b/src/app/dashboard/chat/answer-pdf.ts deleted file mode 100644 index c939088..0000000 --- a/src/app/dashboard/chat/answer-pdf.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * "Download as PDF" for one answer: a clone rendered at a fixed A4 - * width, sliced into pages that never cut through a chart or table row, - * with a header and page-n-of-N footer. - */ -import { toPng } from "html-to-image"; -import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; - -/** A4 at 96 dpi, matching the plan's fixed render width. */ -export const PAGE_WIDTH_PX = 794; -export const PAGE_HEIGHT_PX = 1123; -const HEADER_PX = 44; -const FOOTER_PX = 28; -export const CONTENT_HEIGHT_PX = PAGE_HEIGHT_PX - HEADER_PX - FOOTER_PX; - -/** - * Page-break y-offsets (the first is always 0): the largest run of - * consecutive blocks whose combined height stays within `pageHeight`, - * breaking only *before* a block, never inside one. A single block - * taller than `pageHeight` (an oversized chart) still gets its own page - * rather than being split — there is no good cut point inside it. - */ -export function slicePages(blockTops: number[], blockHeights: number[], pageHeight: number): number[] { - if (blockTops.length === 0) return [0]; - const breaks: number[] = [0]; - let pageStart = blockTops[0]; - for (let i = 0; i < blockTops.length; i++) { - const top = blockTops[i]; - const bottom = top + blockHeights[i]; - if (bottom - pageStart > pageHeight && top > pageStart) { - breaks.push(top); - pageStart = top; - } - } - return breaks; -} - -/** "The Hills fuel report" -> "the-hills-fuel-report", capped for a sane filename. */ -export function slugify(text: string): string { - const slug = text - .toLowerCase() - .normalize("NFD") - .replace(/[̀-ͯ]/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return (slug || "answer").slice(0, 60); -} - -/** Direct children of `el`, used both to render and to compute page breaks. */ -function topLevelBlocks(el: HTMLElement): HTMLElement[] { - return Array.from(el.children).filter((c): c is HTMLElement => c instanceof HTMLElement); -} - -/** - * html-to-image serializes the clone to a standalone SVG document and - * draws *that* to a canvas — it never re-resolves CSS custom properties - * in that step. Recharts sets `fill`/`stroke` straight to `var(--series-1)` - * etc. as SVG attributes (see report-chart.tsx's `seriesColor`), so every - * bar/line/donut segment loses its color: an unresolvable `fill="var(...)"` - * falls back to SVG's fill default (black, hence solid black bars) and an - * unresolvable `stroke="var(...)"` falls back to the stroke default - * ("none", hence an invisible line) — axes and grid keep their look only - * because dark text/grey lines happen to be a plausible fallback too. - * Bake every such attribute down to its computed literal color first. - * Reads from `root` itself, which must already be connected to the - * document with its final (light-forced) theme applied, so the computed - * value matches what capture is meant to show. - */ -function bakeCssVariableColors(root: Element): void { - const elements = [root, ...Array.from(root.querySelectorAll("*"))]; - for (const el of elements) { - for (const attr of ["fill", "stroke"]) { - const raw = el.getAttribute(attr); - if (!raw || !raw.includes("var(")) continue; - const resolved = getComputedStyle(el).getPropertyValue(attr).trim(); - if (resolved) el.setAttribute(attr, resolved); - } - } -} - -/** - * Clones `el` at a fixed A4 width in light theme, renders it to PNG, - * slices it into A4 pages without cutting a block, and returns a PDF - * Blob with a header and "Page n of N" footer. - */ -export async function answerToPdf( - el: HTMLElement, - meta: { title: string; date: string }, -): Promise { - const blocks = topLevelBlocks(el); - const blockTops = blocks.map((b) => b.offsetTop); - const blockHeights = blocks.map((b) => b.offsetHeight); - const totalHeight = el.scrollHeight || blockTops.reduce((max, t, i) => Math.max(max, t + blockHeights[i]), 0); - - const clone = el.cloneNode(true) as HTMLElement; - clone.style.width = `${PAGE_WIDTH_PX}px`; - clone.classList.add("light"); - clone.style.colorScheme = "light"; - - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.left = "-10000px"; - host.style.top = "0"; - host.style.width = `${PAGE_WIDTH_PX}px`; - host.appendChild(clone); - document.body.appendChild(host); - bakeCssVariableColors(clone); - - let dataUrl: string; - try { - dataUrl = await toPng(clone, { - pixelRatio: 2, - width: PAGE_WIDTH_PX, - height: totalHeight, - backgroundColor: "#ffffff", - }); - } finally { - document.body.removeChild(host); - } - - const breaks = slicePages(blockTops, blockHeights, CONTENT_HEIGHT_PX); - - const img = await loadImage(dataUrl); - - const pdf = await PDFDocument.create(); - const font = await pdf.embedFont(StandardFonts.Helvetica); - // The PNG was rendered at pixelRatio 2 off our fixed CSS width, so this - // factor converts CSS-px block offsets into the image's own device - // pixels for cropping. - const devicePxPerCssPx = img.width / PAGE_WIDTH_PX; - const contentTopY = PAGE_HEIGHT_PX - HEADER_PX; - - for (let i = 0; i < breaks.length; i++) { - const sliceTop = breaks[i]; - const sliceBottom = i + 1 < breaks.length ? breaks[i + 1] : totalHeight; - // Each page gets its own crop of exactly the content it shows — never - // the full-height image positioned by page bounds alone. A page that - // breaks early (to avoid splitting a block) is shorter than - // CONTENT_HEIGHT_PX; drawing the full image there let the page's - // fixed-height rectangle bleed into the next block's content, - // duplicating it across the page boundary. - const sliceHeightPx = Math.min(sliceBottom - sliceTop, CONTENT_HEIGHT_PX); - - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = Math.max(1, Math.round(sliceHeightPx * devicePxPerCssPx)); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Could not get a 2D canvas context to crop the rendered answer."); - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage( - img, - 0, - sliceTop * devicePxPerCssPx, - img.width, - canvas.height, - 0, - 0, - canvas.width, - canvas.height, - ); - const pageBytes = await fetch(canvas.toDataURL("image/png")).then((r) => r.arrayBuffer()); - const pagePng = await pdf.embedPng(pageBytes); - - const page = pdf.addPage([PAGE_WIDTH_PX, PAGE_HEIGHT_PX]); - - page.drawText(`MEGA INFRASTRUCTURE · Ask report · ${meta.date}`, { - x: 24, - y: PAGE_HEIGHT_PX - 26, - size: 9, - font, - color: rgb(0.45, 0.45, 0.45), - }); - - page.drawImage(pagePng, { - x: 0, - y: contentTopY - sliceHeightPx, - width: PAGE_WIDTH_PX, - height: sliceHeightPx, - }); - - page.drawText(`Page ${i + 1} of ${breaks.length}`, { - x: PAGE_WIDTH_PX - 90, - y: 16, - size: 8, - font, - color: rgb(0.55, 0.55, 0.55), - }); - } - - const bytes = await pdf.save(); - return new Blob([new Uint8Array(bytes)], { type: "application/pdf" }); -} - -function loadImage(src: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error("Could not load the rendered answer image.")); - img.src = src; - }); -} diff --git a/src/app/dashboard/chat/chat-view.tsx b/src/app/dashboard/chat/chat-view.tsx index fb49b88..0de3dee 100644 --- a/src/app/dashboard/chat/chat-view.tsx +++ b/src/app/dashboard/chat/chat-view.tsx @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"; import { AnswerMarkdown } from "./answer-markdown"; import { UploadCard, type UploadState } from "./upload-card"; import { ActionCard, type ActionCardData } from "./action-card"; -import { answerToPdf, slugify } from "./answer-pdf"; +import { DownloadPdfButton, downloadMessagePdf } from "./download-pdf-button"; import { nextPdfTurnToDownload } from "./pdf-auto-download"; import type { PdfReportMeta } from "@/lib/agent/pdf-report"; import type { ChartSpec } from "@/lib/reports/chart-spec"; @@ -38,6 +38,8 @@ type Turn = actionIds?: string[]; /** Set once make_pdf_report fires — turns this answer into a report. */ pdf?: PdfReportMeta; + /** The stored `ask_messages` row id — set from the reload or once "done" reports it. Needed for GET /api/ask/messages/[id]/pdf. */ + id?: string; local?: boolean; } | { role: "upload"; upload: UploadState }; @@ -118,6 +120,7 @@ function relativeTime(iso?: string): string { /** Turns a stored `ask_messages` row into a UI Turn. */ function rowToTurn(row: { + id: string; kind: string; text: string; display?: string; @@ -132,6 +135,7 @@ function rowToTurn(row: { if (row.kind === "assistant") { return { role: "assistant", + id: row.id, text: row.text, charts: row.charts ?? [], tools: (row.tools ?? []).filter((t) => t !== "error"), @@ -495,46 +499,27 @@ function ChatViewInner() { } }, []); - const [pdfBusyIndex, setPdfBusyIndex] = React.useState(null); - - const downloadAnswerAsPdf = React.useCallback( - async (index: number) => { - const el = document.getElementById(`answer-${index}`); - if (!el) return; - setPdfBusyIndex(index); - try { - const turn = turns[index]; - const report = turn?.role === "assistant" ? turn.pdf : undefined; - const title = report?.title || threads.find((t) => t.id === activeThreadId)?.title || "answer"; - const date = new Date().toISOString().slice(0, 10); - const blob = await answerToPdf(el, { title, date }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const filename = report?.filename || `ask-${slugify(title)}-${date}.pdf`; - a.download = filename.endsWith(".pdf") ? filename : `${filename}.pdf`; - a.click(); - URL.revokeObjectURL(url); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setPdfBusyIndex(null); - } - }, - [threads, activeThreadId, turns], - ); - - // "Once per turn" auto-download for a finished Ask-made report. + // "Once per turn" auto-download for a finished Ask-made report — the + // server route (GET /api/ask/messages/[id]/pdf) needs the message's own + // stored id, which only exists once "done" has reported it (see the + // `event === "done"` handler above), so this waits for that too. const pdfDownloadedThroughRef = React.useRef(0); React.useEffect(() => { const index = nextPdfTurnToDownload(turns, busy, pdfDownloadedThroughRef.current); if (index === null) return; + const turn = turns[index]; + const messageId = turn?.role === "assistant" ? turn.id : undefined; + if (!messageId || !actor) return; pdfDownloadedThroughRef.current = turns.length; - // Deferred a microtask so downloadAnswerAsPdf's setState calls run as - // an async continuation, not synchronously in the effect body — see - // the identical note on the actor-loading effect above. - Promise.resolve().then(() => downloadAnswerAsPdf(index)); - }, [turns, busy, downloadAnswerAsPdf]); + // Deferred a microtask so the download's own state stays an async + // continuation, not synchronous in the effect body — see the + // identical note on the actor-loading effect above. + Promise.resolve().then(() => + downloadMessagePdf(messageId, actor.id).catch((err) => + setError(err instanceof Error ? err.message : String(err)), + ), + ); + }, [turns, busy, actor]); // Pending photos attached to the next user turn. Declared before any // handler that reads it; useCallback closes over this state at the @@ -731,6 +716,17 @@ function ChatViewInner() { // without the model signalling completion. Surface this so // a quiet stream end isn't mistaken for a complete answer. if (data?.stoppedOnLimit) setStoppedOnLimit(true); + // The persisted row's own id — GET /api/ask/messages/[id]/pdf + // needs it for both the manual button and the auto-download. + if (data?.messageId) { + setTurns((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last.role !== "assistant") return prev; + next[next.length - 1] = { ...last, id: data.messageId as string }; + return next; + }); + } } } } @@ -1230,13 +1226,13 @@ function ChatViewInner() { )} {t.pdf && !streaming && ( - + /> )} {/* Provenance: which tools produced this answer. The point @@ -1259,13 +1255,13 @@ function ChatViewInner() { > Print - + /> )} diff --git a/src/app/dashboard/chat/download-pdf-button.tsx b/src/app/dashboard/chat/download-pdf-button.tsx new file mode 100644 index 0000000..fe14e8a --- /dev/null +++ b/src/app/dashboard/chat/download-pdf-button.tsx @@ -0,0 +1,74 @@ +"use client"; + +import * as React from "react"; + +/** + * Fetches the server-rendered PDF for one stored assistant message + * (`GET /api/ask/messages/[id]/pdf` — see src/lib/reports/pdf/build.ts) + * and saves it under the filename the server chose (its + * `Content-Disposition`). No DOM screenshot: the route already returns + * a real, selectable PDF. + */ +export async function downloadMessagePdf(messageId: string, actorId: string): Promise { + const res = await fetch(`/api/ask/messages/${messageId}/pdf`, { + headers: { "x-ask-user": actorId }, + }); + if (!res.ok) throw new Error(`Could not build the PDF (HTTP ${res.status}).`); + const blob = await res.blob(); + + const disposition = res.headers.get("content-disposition") ?? ""; + const match = /filename="([^"]+)"/.exec(disposition); + const filename = match?.[1] ?? "ask-report.pdf"; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * A "Download PDF" trigger for one message. Its own small component (not + * inlined in chat-view.tsx) since another change is landing in that file + * at the same time — this keeps that diff small. + */ +export function DownloadPdfButton({ + messageId, + actorId, + label, + className, + disabled, + busyLabel = "Rendering…", + onError, +}: { + messageId?: string; + actorId?: string; + label: string; + className?: string; + disabled?: boolean; + busyLabel?: string; + onError?: (message: string) => void; +}) { + const [busy, setBusy] = React.useState(false); + + const onClick = React.useCallback(async () => { + if (!messageId || !actorId) return; + setBusy(true); + try { + await downloadMessagePdf(messageId, actorId); + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, [messageId, actorId, onError]); + + return ( + + ); +}