feat: add public report PDF preview endpoint and update report content handling for Typst-based resumes

This commit is contained in:
Anish Sarkar 2026-04-16 22:51:36 +05:30
parent 6037058a09
commit e2cd0557a5
6 changed files with 65 additions and 9 deletions

View file

@ -466,7 +466,7 @@ _TOOL_INSTRUCTIONS["generate_resume"] = """
- parent_report_id: Set this when the user wants to MODIFY an existing resume from - parent_report_id: Set this when the user wants to MODIFY an existing resume from
this conversation. Use the report_id from a previous generate_resume result. this conversation. Use the report_id from a previous generate_resume result.
- Returns: Dict with status, report_id, title, and content_type. - Returns: Dict with status, report_id, title, and content_type.
- After calling: Give a brief confirmation. Do NOT paste resume content in chat. - After calling: Give a brief confirmation. Do NOT paste resume content in chat. Do NOT mention report_id or any internal IDs the resume card is shown automatically.
- VERSIONING: Same rules as generate_report set parent_report_id for modifications - VERSIONING: Same rules as generate_report set parent_report_id for modifications
of an existing resume, leave as None for new resumes. of an existing resume, leave as None for new resumes.
""" """

View file

@ -231,6 +231,57 @@ def _replace_audio_paths_with_public_urls(
return result return result
@router.get("/{share_token}/reports/{report_id}/preview")
async def preview_public_report_pdf(
share_token: str,
report_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Return a compiled PDF preview for a Typst-based report in a public snapshot.
No authentication required - the share_token provides access.
"""
import asyncio
import io
import re
import typst as typst_compiler
report_info = await get_snapshot_report(session, share_token, report_id)
if not report_info:
raise HTTPException(status_code=404, detail="Report not found")
content = report_info.get("content")
content_type = report_info.get("content_type", "markdown")
if not content:
raise HTTPException(status_code=400, detail="Report has no content to preview")
if content_type != "typst":
raise HTTPException(
status_code=400,
detail="Preview is only available for Typst-based reports",
)
def _compile() -> bytes:
return typst_compiler.compile(content.encode("utf-8"))
pdf_bytes = await asyncio.to_thread(_compile)
safe_title = re.sub(r"[^\w\s-]", "", report_info.get("title") or "Resume").strip()
filename = f"{safe_title}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{filename}"',
},
)
@router.get("/{share_token}/reports/{report_id}/content") @router.get("/{share_token}/reports/{report_id}/content")
async def get_public_report_content( async def get_public_report_content(
share_token: str, share_token: str,
@ -259,6 +310,7 @@ async def get_public_report_content(
"id": report_info.get("original_id"), "id": report_info.get("original_id"),
"title": report_info.get("title"), "title": report_info.get("title"),
"content": report_info.get("content"), "content": report_info.get("content"),
"content_type": report_info.get("content_type", "markdown"),
"report_metadata": report_info.get("report_metadata"), "report_metadata": report_info.get("report_metadata"),
"report_group_id": report_info.get("report_group_id"), "report_group_id": report_info.get("report_group_id"),
"versions": versions, "versions": versions,

View file

@ -41,6 +41,7 @@ UI_TOOLS = {
"generate_image", "generate_image",
"generate_podcast", "generate_podcast",
"generate_report", "generate_report",
"generate_resume",
"generate_video_presentation", "generate_video_presentation",
} }
@ -239,7 +240,7 @@ async def create_snapshot(
video_presentation_ids_seen.add(vp_id) video_presentation_ids_seen.add(vp_id)
part["result"] = {**result_data, "status": "ready"} part["result"] = {**result_data, "status": "ready"}
elif tool_name == "generate_report": elif tool_name in ("generate_report", "generate_resume"):
result_data = part.get("result", {}) result_data = part.get("result", {})
report_id = result_data.get("report_id") report_id = result_data.get("report_id")
if report_id and report_id not in report_ids_seen: if report_id and report_id not in report_ids_seen:
@ -247,7 +248,6 @@ async def create_snapshot(
if report_info: if report_info:
reports_data.append(report_info) reports_data.append(report_info)
report_ids_seen.add(report_id) report_ids_seen.add(report_id)
# Update status to "ready" so frontend renders ReportCard
part["result"] = {**result_data, "status": "ready"} part["result"] = {**result_data, "status": "ready"}
messages_data.append( messages_data.append(
@ -377,6 +377,7 @@ async def _get_report_for_snapshot(
"original_id": report.id, "original_id": report.id,
"title": report.title, "title": report.title,
"content": report.content, "content": report.content,
"content_type": report.content_type,
"report_metadata": report.report_metadata, "report_metadata": report.report_metadata,
"report_group_id": report.report_group_id, "report_group_id": report.report_group_id,
"created_at": report.created_at.isoformat() if report.created_at else None, "created_at": report.created_at.isoformat() if report.created_at else None,

View file

@ -18,6 +18,7 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image"; import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
import { GenerateResumeToolUI } from "@/components/tool-ui/generate-resume";
const GenerateVideoPresentationToolUI = dynamic( const GenerateVideoPresentationToolUI = dynamic(
() => () =>
@ -160,6 +161,7 @@ const PublicAssistantMessage: FC = () => {
by_name: { by_name: {
generate_podcast: GeneratePodcastToolUI, generate_podcast: GeneratePodcastToolUI,
generate_report: GenerateReportToolUI, generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI, generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI, display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI, generate_image: GenerateImageToolUI,

View file

@ -379,9 +379,9 @@ export function ReportPanelContent({
</div> </div>
</div> </div>
) : reportContent.content_type === "typst" ? ( ) : reportContent.content_type === "typst" ? (
<PdfViewer <PdfViewer
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/preview`} pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
/> />
) : reportContent.content ? ( ) : reportContent.content ? (
isReadOnly ? ( isReadOnly ? (
<div className="h-full overflow-y-auto px-5 py-4"> <div className="h-full overflow-y-auto px-5 py-4">

View file

@ -182,9 +182,10 @@ function ResumeCard({
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading"); const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => { useEffect(() => {
setPdfUrl( const previewPath = shareToken
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/preview` ? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
); : `/api/v1/reports/${reportId}/preview`;
setPdfUrl(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${previewPath}`);
if (autoOpen && isDesktop && !autoOpenedRef.current) { if (autoOpen && isDesktop && !autoOpenedRef.current) {
autoOpenedRef.current = true; autoOpenedRef.current = true;