feat(ask): download real PDFs from the chat UI, drop the screenshot path
The "Download PDF" button (both the report CTA and the per-answer icon) and the once-per-report auto-download now fetch GET /api/ask/messages/[id]/pdf instead of screenshotting the DOM with html-to-image. That fetch/blob/anchor-download logic lives in its own new download-pdf-button.tsx, kept small and separate from chat-view.tsx since another change is landing in that file's sidebar at the same time. A Turn's `id` (the stored ask_messages row id) now rides in from a thread reload (rowToTurn) or from the stream's "done" event once the row is persisted; both the button and the auto-download wait for it. Removes answer-pdf.ts (the html-to-image renderer and its page-slicing math) and its tests — nothing else used them. slugify moved to src/lib/reports/pdf/filename.ts in an earlier commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
58b625701e
commit
2f613181ac
4 changed files with 118 additions and 303 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Blob> {
|
||||
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<HTMLImageElement> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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<number | null>(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 && (
|
||||
<button
|
||||
onClick={() => downloadAnswerAsPdf(i)}
|
||||
disabled={pdfBusyIndex === i}
|
||||
<DownloadPdfButton
|
||||
messageId={t.id}
|
||||
actorId={actor?.id}
|
||||
label={`Download PDF · ${t.pdf.title}`}
|
||||
onError={setError}
|
||||
className="print-hide mt-3 flex items-center gap-1.5 rounded-lg bg-[#F2B165] px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-[#E08A3C] disabled:opacity-50"
|
||||
>
|
||||
{pdfBusyIndex === i ? "Rendering…" : `Download PDF · ${t.pdf.title}`}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Provenance: which tools produced this answer. The point
|
||||
|
|
@ -1259,13 +1255,13 @@ function ChatViewInner() {
|
|||
>
|
||||
Print
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadAnswerAsPdf(i)}
|
||||
disabled={pdfBusyIndex === i}
|
||||
<DownloadPdfButton
|
||||
messageId={t.id}
|
||||
actorId={actor?.id}
|
||||
label="PDF"
|
||||
onError={setError}
|
||||
className="print-hide rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-[#F2B165]/50 hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{pdfBusyIndex === i ? "Rendering…" : "PDF"}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
74
src/app/dashboard/chat/download-pdf-button.tsx
Normal file
74
src/app/dashboard/chat/download-pdf-button.tsx
Normal file
|
|
@ -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<void> {
|
||||
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 (
|
||||
<button type="button" onClick={onClick} disabled={disabled || busy || !messageId || !actorId} className={className}>
|
||||
{busy ? busyLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue