diff --git a/surfsense_backend/app/agents/new_chat/tools/resume.py b/surfsense_backend/app/agents/new_chat/tools/resume.py index 8295dd1b5..087cb5e26 100644 --- a/surfsense_backend/app/agents/new_chat/tools/resume.py +++ b/surfsense_backend/app/agents/new_chat/tools/resume.py @@ -362,9 +362,10 @@ def create_generate_resume_tool( {"phase": "saving", "message": "Saving your resume"}, ) - # Extract a title from the Typst source (the = heading) + # Extract a title from the Typst source (the = heading is the person's name) title_match = re.search(r"^=\s+(.+)$", typst_source, re.MULTILINE) - resume_title = title_match.group(1).strip() if title_match else "Resume" + name = title_match.group(1).strip() if title_match else None + resume_title = f"{name} - Resume" if name else "Resume" metadata: dict[str, Any] = { "status": "ready", diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index f159d42d2..fd1f6518b 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -71,6 +71,13 @@ const GenerateReportToolUI = dynamic( })), { ssr: false } ); +const GenerateResumeToolUI = dynamic( + () => + import("@/components/tool-ui/generate-resume").then((m) => ({ + default: m.GenerateResumeToolUI, + })), + { ssr: false } +); const GeneratePodcastToolUI = dynamic( () => import("@/components/tool-ui/generate-podcast").then((m) => ({ @@ -487,6 +494,7 @@ const AssistantMessageInner: FC = () => { tools: { by_name: { generate_report: GenerateReportToolUI, + generate_resume: GenerateResumeToolUI, generate_podcast: GeneratePodcastToolUI, generate_video_presentation: GenerateVideoPresentationToolUI, display_image: GenerateImageToolUI, diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx new file mode 100644 index 000000000..eb79431be --- /dev/null +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { ChevronLeftIcon, ChevronRightIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; +import { Button } from "@/components/ui/button"; +import { getAuthHeaders } from "@/lib/auth-utils"; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + +interface PdfViewerProps { + pdfUrl: string; +} + +const ZOOM_STEP = 0.15; +const MIN_ZOOM = 0.5; +const MAX_ZOOM = 3; + +export function PdfViewer({ pdfUrl }: PdfViewerProps) { + const [numPages, setNumPages] = useState(0); + const [pageNumber, setPageNumber] = useState(1); + const [scale, setScale] = useState(1); + const [loadError, setLoadError] = useState(null); + const containerRef = useRef(null); + + const documentOptions = useMemo(() => ({ httpHeaders: getAuthHeaders() }), []); + + const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { + setNumPages(numPages); + setPageNumber(1); + setLoadError(null); + }, []); + + const onDocumentLoadError = useCallback((error: Error) => { + setLoadError(error.message || "Failed to load PDF"); + }, []); + + const goToPrevPage = useCallback(() => { + setPageNumber((prev) => Math.max(1, prev - 1)); + }, []); + + const goToNextPage = useCallback(() => { + setPageNumber((prev) => Math.min(numPages, prev + 1)); + }, [numPages]); + + const zoomIn = useCallback(() => { + setScale((prev) => Math.min(MAX_ZOOM, prev + ZOOM_STEP)); + }, []); + + const zoomOut = useCallback(() => { + setScale((prev) => Math.max(MIN_ZOOM, prev - ZOOM_STEP)); + }, []); + + if (loadError) { + return ( +
+

Failed to load resume preview

+

{loadError}

+
+ ); + } + + return ( +
+ {/* Controls bar */} + {numPages > 0 && ( +
+ {numPages > 1 && ( + <> + + + {pageNumber} / {numPages} + + +
+ + )} + + + {Math.round(scale * 100)}% + + +
+ )} + + {/* PDF content */} +
+ +
+
+ } + > + + Failed to render page {pageNumber} +
+ } + /> + +
+
+ ); +} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 6ec2a08eb..de1527a77 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -53,6 +53,11 @@ const PlateEditor = dynamic( { ssr: false, loading: () => } ); +const PdfViewer = dynamic( + () => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })), + { ssr: false, loading: () => } +); + /** * Zod schema for a single version entry */ @@ -68,6 +73,7 @@ const ReportContentResponseSchema = z.object({ id: z.number(), title: z.string(), content: z.string().nullish(), + content_type: z.string().default("markdown"), report_metadata: z .object({ status: z.enum(["ready", "failed"]).nullish(), @@ -318,6 +324,7 @@ export function ReportPanelContent({ onExport={handleExport} exporting={exporting} showAllFormats={!shareToken} + pdfOnly={reportContent?.content_type === "typst"} /> @@ -371,6 +378,10 @@ export function ReportPanelContent({

{error || "An unknown error occurred"}

+ ) : reportContent.content_type === "typst" ? ( + ) : reportContent.content ? ( isReadOnly ? (
diff --git a/surfsense_web/components/shared/ExportMenuItems.tsx b/surfsense_web/components/shared/ExportMenuItems.tsx index 86bc12c42..3f90bed77 100644 --- a/surfsense_web/components/shared/ExportMenuItems.tsx +++ b/surfsense_web/components/shared/ExportMenuItems.tsx @@ -24,18 +24,30 @@ interface ExportMenuItemsProps { exporting: string | null; /** Hide server-side formats (PDF, DOCX, etc.) — only show md */ showAllFormats?: boolean; + /** When true, only show PDF export (used for Typst-based resumes) */ + pdfOnly?: boolean; } export function ExportDropdownItems({ onExport, exporting, showAllFormats = true, + pdfOnly = false, }: ExportMenuItemsProps) { const handle = (format: string) => (e: React.MouseEvent) => { e.stopPropagation(); onExport(format); }; + if (pdfOnly) { + return ( + + {exporting === "pdf" && } + PDF (.pdf) + + ); + } + return ( <> {showAllFormats && (