Merge pull request #1240 from AnishSarkar22/feat/resume-builder
Some checks failed
Build and Push Docker Images / tag_release (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Has been cancelled

feat: resume builder
This commit is contained in:
Rohan Verma 2026-04-17 13:41:32 -07:00 committed by GitHub
commit 2b2453e015
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1917 additions and 60 deletions

View file

@ -162,6 +162,7 @@ const TOOLS_WITH_UI = new Set([
"web_search",
"generate_podcast",
"generate_report",
"generate_resume",
"generate_video_presentation",
"display_image",
"generate_image",

View file

@ -8,6 +8,8 @@ interface ReportPanelState {
wordCount: number | null;
/** When set, uses public endpoints for fetching report data (public shared chat) */
shareToken: string | null;
/** Content type of the report — "markdown" (default) or "typst" (resume) */
contentType: string;
}
const initialState: ReportPanelState = {
@ -16,6 +18,7 @@ const initialState: ReportPanelState = {
title: null,
wordCount: null,
shareToken: null,
contentType: "markdown",
};
/** Core atom holding the report panel state */
@ -38,7 +41,14 @@ export const openReportPanelAtom = atom(
title,
wordCount,
shareToken,
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
contentType,
}: {
reportId: number;
title: string;
wordCount?: number;
shareToken?: string | null;
contentType?: string;
}
) => {
if (!get(reportPanelAtom).isOpen) {
set(preReportCollapsedAtom, get(rightPanelCollapsedAtom));
@ -49,6 +59,7 @@ export const openReportPanelAtom = atom(
title,
wordCount: wordCount ?? null,
shareToken: shareToken ?? null,
contentType: contentType ?? "markdown",
});
set(rightPanelTabAtom, "report");
set(rightPanelCollapsedAtom, false);

View file

@ -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,
@ -537,7 +545,7 @@ const AssistantMessageInner: FC = () => {
</div>
)}
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex items-center gap-2">
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 flex items-center gap-2">
<AssistantActionBar />
</div>
</CitationMetadataProvider>

View file

@ -68,7 +68,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
size="lg"
onClick={handleCopyAndContinue}
disabled={isCloning}
className="gap-2 rounded-full px-6 shadow-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
className="gap-2 rounded-full px-6 shadow-lg transition-al select-none duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
>
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
Copy and continue this chat

View file

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

View file

@ -0,0 +1,354 @@
"use client";
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthHeaders } from "@/lib/auth-utils";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
interface PdfViewerProps {
pdfUrl: string;
isPublic?: boolean;
}
interface PageDimensions {
width: number;
height: number;
}
const ZOOM_STEP = 0.15;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3;
const PAGE_GAP = 12;
const SCROLL_DEBOUNCE_MS = 30;
const BUFFER_PAGES = 1;
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
const [numPages, setNumPages] = useState(0);
const [scale, setScale] = useState(1);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const pdfDocRef = useRef<PDFDocumentProxy | null>(null);
const canvasRefs = useRef<Map<number, HTMLCanvasElement>>(new Map());
const renderTasksRef = useRef<Map<number, RenderTask>>(new Map());
const renderedScalesRef = useRef<Map<number, number>>(new Map());
const pageDimsRef = useRef<PageDimensions[]>([]);
const visiblePagesRef = useRef<Set<number>>(new Set());
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getScaledHeight = useCallback(
(pageIndex: number) => {
const dims = pageDimsRef.current[pageIndex];
return dims ? Math.floor(dims.height * scale) : 0;
},
[scale]
);
const getVisibleRange = useCallback(() => {
const container = scrollContainerRef.current;
if (!container || pageDimsRef.current.length === 0) return { first: 1, last: 1 };
const scrollTop = container.scrollTop;
const viewportHeight = container.clientHeight;
const scrollBottom = scrollTop + viewportHeight;
let cumTop = 16;
let first = 1;
let last = pageDimsRef.current.length;
for (let i = 0; i < pageDimsRef.current.length; i++) {
const pageHeight = getScaledHeight(i);
const pageBottom = cumTop + pageHeight;
if (pageBottom >= scrollTop && first === 1) {
first = i + 1;
}
if (cumTop > scrollBottom) {
last = i;
break;
}
cumTop = pageBottom + PAGE_GAP;
}
first = Math.max(1, first - BUFFER_PAGES);
last = Math.min(pageDimsRef.current.length, last + BUFFER_PAGES);
return { first, last };
}, [getScaledHeight]);
const renderPage = useCallback(async (pageNum: number, currentScale: number) => {
const pdf = pdfDocRef.current;
const canvas = canvasRefs.current.get(pageNum);
if (!pdf || !canvas) return;
if (renderedScalesRef.current.get(pageNum) === currentScale) return;
const existing = renderTasksRef.current.get(pageNum);
if (existing) {
existing.cancel();
renderTasksRef.current.delete(pageNum);
}
try {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: currentScale });
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const renderTask = page.render({
canvas,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
});
renderTasksRef.current.set(pageNum, renderTask);
await renderTask.promise;
renderTasksRef.current.delete(pageNum);
renderedScalesRef.current.set(pageNum, currentScale);
page.cleanup();
} catch (err: unknown) {
if (err instanceof Error && err.message?.includes("cancelled")) return;
console.error(`Failed to render page ${pageNum}:`, err);
}
}, []);
const cleanupPage = useCallback((pageNum: number) => {
const existing = renderTasksRef.current.get(pageNum);
if (existing) {
existing.cancel();
renderTasksRef.current.delete(pageNum);
}
const canvas = canvasRefs.current.get(pageNum);
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
}
renderedScalesRef.current.delete(pageNum);
}, []);
const renderVisiblePages = useCallback(() => {
if (!pdfDocRef.current || pageDimsRef.current.length === 0) return;
const { first, last } = getVisibleRange();
const newVisible = new Set<number>();
for (let i = first; i <= last; i++) {
newVisible.add(i);
renderPage(i, scale);
}
for (const pageNum of visiblePagesRef.current) {
if (!newVisible.has(pageNum)) {
cleanupPage(pageNum);
}
}
visiblePagesRef.current = newVisible;
}, [getVisibleRange, renderPage, cleanupPage, scale]);
useEffect(() => {
let cancelled = false;
const loadDocument = async () => {
setLoading(true);
setLoadError(null);
setNumPages(0);
pageDimsRef.current = [];
try {
const loadingTask = pdfjsLib.getDocument({
url: pdfUrl,
httpHeaders: getAuthHeaders(),
});
const pdf = await loadingTask.promise;
if (cancelled) {
pdf.destroy();
return;
}
const dims: PageDimensions[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1 });
dims.push({ width: viewport.width, height: viewport.height });
page.cleanup();
}
if (cancelled) {
pdf.destroy();
return;
}
pdfDocRef.current = pdf;
pageDimsRef.current = dims;
setNumPages(pdf.numPages);
setLoading(false);
} catch (err: unknown) {
if (cancelled) return;
const message = err instanceof Error ? err.message : "Failed to load PDF";
setLoadError(message);
setLoading(false);
}
};
loadDocument();
return () => {
cancelled = true;
for (const task of renderTasksRef.current.values()) {
task.cancel();
}
renderTasksRef.current.clear();
renderedScalesRef.current.clear();
visiblePagesRef.current.clear();
pdfDocRef.current?.destroy();
pdfDocRef.current = null;
};
}, [pdfUrl]);
useEffect(() => {
if (numPages === 0) return;
renderedScalesRef.current.clear();
visiblePagesRef.current.clear();
const frame = requestAnimationFrame(() => {
renderVisiblePages();
});
return () => cancelAnimationFrame(frame);
}, [numPages, renderVisiblePages]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || numPages === 0) return;
const handleScroll = () => {
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = setTimeout(() => {
renderVisiblePages();
}, SCROLL_DEBOUNCE_MS);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
};
}, [numPages, renderVisiblePages]);
const setCanvasRef = useCallback((pageNum: number, el: HTMLCanvasElement | null) => {
if (el) {
canvasRefs.current.set(pageNum, el);
} else {
canvasRefs.current.delete(pageNum);
}
}, []);
const zoomIn = useCallback(() => {
setScale((prev) => Math.min(MAX_ZOOM, +(prev + ZOOM_STEP).toFixed(2)));
}, []);
const zoomOut = useCallback(() => {
setScale((prev) => Math.max(MIN_ZOOM, +(prev - ZOOM_STEP).toFixed(2)));
}, []);
if (loadError) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-6 text-center">
<p className="font-medium text-foreground">Failed to load PDF</p>
<p className="text-sm text-muted-foreground">{loadError}</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{numPages > 0 && (
<div
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
>
<Button
variant="ghost"
size="icon"
onClick={zoomOut}
disabled={scale <= MIN_ZOOM}
className="size-7"
>
<ZoomOutIcon className="size-4" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
{Math.round(scale * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={zoomIn}
disabled={scale >= MAX_ZOOM}
className="size-7"
>
<ZoomInIcon className="size-4" />
</Button>
</div>
)}
<div
ref={scrollContainerRef}
className={`relative flex-1 overflow-auto ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
>
{loading ? (
<div
className={`absolute inset-0 flex items-center justify-center ${isPublic ? "text-foreground" : "text-sidebar-foreground"}`}
>
<Spinner size="md" />
</div>
) : (
<div className="flex flex-col items-center py-4" style={{ gap: `${PAGE_GAP}px` }}>
{pageDimsRef.current.map((dims, i) => {
const pageNum = i + 1;
const scaledWidth = Math.floor(dims.width * scale);
const scaledHeight = Math.floor(dims.height * scale);
return (
<div
key={pageNum}
className="relative shrink-0"
style={{ width: scaledWidth, height: scaledHeight }}
>
<canvas
ref={(el) => setCanvasRef(pageNum, el)}
className="shadow-lg absolute inset-0"
/>
{numPages > 1 && (
<span className="absolute bottom-2 right-3 text-[10px] tabular-nums text-white/80 bg-black/50 px-1.5 py-0.5 rounded pointer-events-none">
Page {pageNum}/{numPages}
</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -18,6 +18,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -53,6 +54,11 @@ const PlateEditor = dynamic(
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
);
const PdfViewer = dynamic(
() => import("@/components/report-panel/pdf-viewer").then((m) => ({ default: m.PdfViewer })),
{ ssr: false, loading: () => <ReportPanelSkeleton /> }
);
/**
* Zod schema for a single version entry
*/
@ -68,6 +74,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(),
@ -280,47 +287,63 @@ export function ReportPanelContent({
}, [activeReportId, currentMarkdown]);
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
const isPublic = !!shareToken;
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
return (
<>
{/* Action bar — always visible; buttons are disabled while loading */}
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
{/* Copy button */}
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px] bg-sidebar select-none"
>
{copied ? "Copied" : "Copy"}
</Button>
{/* Export dropdown */}
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
{/* Copy button — hidden for Typst (resume) */}
{reportContent?.content_type !== "typst" && (
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className={`h-8 min-w-[80px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
{copied ? "Copied" : "Copy"}
</Button>
)}
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
>
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
</Button>
) : (
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 && (
@ -329,7 +352,7 @@ export function ReportPanelContent({
<Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5 bg-sidebar select-none"
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
@ -365,12 +388,17 @@ export function ReportPanelContent({
{isLoading ? (
<ReportPanelSkeleton />
) : error || !reportContent ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center select-none">
<div>
<p className="font-medium text-foreground">Failed to load report</p>
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : reportContent.content_type === "typst" ? (
<PdfViewer
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
isPublic={isPublic}
/>
) : reportContent.content ? (
isReadOnly ? (
<div className="h-full overflow-y-auto px-5 py-4">
@ -421,10 +449,12 @@ function DesktopReportPanel() {
if (!panelState.isOpen || !panelState.reportId) return null;
const isPublic = !!panelState.shareToken;
return (
<div
ref={panelRef}
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out"
className={`flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l animate-in slide-in-from-right-4 duration-300 ease-out ${isPublic ? "bg-main-panel text-foreground" : "bg-sidebar text-sidebar-foreground"}`}
>
<ReportPanelContent
reportId={panelState.reportId}
@ -445,6 +475,8 @@ function MobileReportDrawer() {
if (!panelState.reportId) return null;
const isPublic = !!panelState.shareToken;
return (
<Drawer
open={panelState.isOpen}
@ -454,7 +486,7 @@ function MobileReportDrawer() {
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
className={`h-[90vh] max-h-[90vh] z-80 overflow-hidden ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
overlayClassName="z-80"
>
<DrawerHandle />

View file

@ -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 (
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</DropdownMenuItem>
);
}
return (
<>
{showAllFormats && (

View file

@ -96,8 +96,12 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
{title && title !== "Report" && (
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
)}
<p className={`text-sm text-muted-foreground${title && title !== "Report" ? " mt-1" : ""}`}>
{error}
</p>
</div>
</div>
);
@ -215,17 +219,9 @@ function ReportCard({
<div
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
>
{/* biome-ignore lint/a11y/useSemanticElements: can't use <button> here because PlateEditor renders nested <button> elements (e.g. CopyButton) */}
<div
role="button"
tabIndex={0}
<button
type="button"
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOpen();
}
}}
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer"
>
<div className="px-5 pt-5 pb-4 select-none">
@ -272,7 +268,7 @@ function ReportCard({
<p className="text-sm text-muted-foreground italic">No content available</p>
)}
</div>
</div>
</button>
</div>
);
}

View file

@ -0,0 +1,334 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, usePathname } from "next/navigation";
import * as pdfjsLib from "pdfjs-dist";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getAuthHeaders } from "@/lib/auth-utils";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
const GenerateResumeArgsSchema = z.object({
user_info: z.string(),
user_instructions: z.string().nullish(),
parent_report_id: z.number().nullish(),
});
const GenerateResumeResultSchema = z.object({
status: z.enum(["ready", "failed"]),
report_id: z.number().nullish(),
title: z.string().nullish(),
content_type: z.string().nullish(),
message: z.string().nullish(),
error: z.string().nullish(),
});
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
function ResumeGeneratingState() {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground">Resume</p>
</div>
<TextShimmerLoader text="Crafting your resume" size="sm" />
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-4">
<div className="h-[7rem] space-y-2">
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
</div>
</div>
</div>
);
}
function ResumeErrorState({ title, error }: { title: string; error: string }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-destructive">Resume Generation Failed</p>
</div>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
{title && title !== "Resume" && (
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
)}
<p className={`text-sm text-muted-foreground${title && title !== "Resume" ? " mt-1" : ""}`}>
{error}
</p>
</div>
</div>
);
}
function ResumeCancelledState() {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-muted-foreground">Resume Cancelled</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Resume generation was cancelled</p>
</div>
</div>
);
}
function ThumbnailSkeleton() {
return (
<div className="h-[7rem] space-y-2">
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-3 w-[85%] rounded bg-muted/60 animate-pulse [animation-delay:300ms]" />
<div className="h-3 w-[60%] rounded bg-muted/60 animate-pulse [animation-delay:400ms]" />
</div>
);
}
function PdfThumbnail({
pdfUrl,
onLoad,
onError,
}: {
pdfUrl: string;
onLoad: () => void;
onError: () => void;
}) {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const renderThumbnail = async () => {
try {
const loadingTask = pdfjsLib.getDocument({
url: pdfUrl,
httpHeaders: getAuthHeaders(),
});
const pdf = await loadingTask.promise;
if (cancelled) {
pdf.destroy();
return;
}
const page = await pdf.getPage(1);
if (cancelled) {
pdf.destroy();
return;
}
const canvas = canvasRef.current;
if (!canvas) {
pdf.destroy();
return;
}
const containerWidth = wrapperRef.current?.clientWidth || 400;
const unscaledViewport = page.getViewport({ scale: 1 });
const fitScale = containerWidth / unscaledViewport.width;
const viewport = page.getViewport({ scale: fitScale });
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.ceil(viewport.width * dpr);
canvas.height = Math.ceil(viewport.height * dpr);
await page.render({
canvas,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
}).promise;
if (!cancelled) {
setReady(true);
onLoad();
}
pdf.destroy();
} catch {
if (!cancelled) onError();
}
};
renderThumbnail();
return () => {
cancelled = true;
};
}, [pdfUrl, onLoad, onError]);
return (
<div ref={wrapperRef}>
<canvas ref={canvasRef} className={ready ? "w-full h-auto" : "hidden"} />
</div>
);
}
function ResumeCard({
reportId,
title,
shareToken,
autoOpen = false,
}: {
reportId: number;
title: string;
shareToken?: string | null;
autoOpen?: boolean;
}) {
const openPanel = useSetAtom(openReportPanelAtom);
const panelState = useAtomValue(reportPanelAtom);
const isDesktop = useMediaQuery("(min-width: 768px)");
const autoOpenedRef = useRef(false);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
const previewPath = shareToken
? `/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) {
autoOpenedRef.current = true;
openPanel({
reportId,
title,
shareToken,
contentType: "typst",
});
}
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
const onThumbError = useCallback(() => setThumbState("error"), []);
const isActive = panelState.isOpen && panelState.reportId === reportId;
const handleOpen = () => {
openPanel({
reportId,
title,
shareToken,
contentType: "typst",
});
};
return (
<div
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
>
<button
type="button"
onClick={handleOpen}
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
>
<div className="px-5 pt-5 pb-4">
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-4">
{thumbState === "loading" && <ThumbnailSkeleton />}
{thumbState === "error" && (
<p className="text-sm text-muted-foreground">Preview unavailable</p>
)}
{pdfUrl && (
<div
className={`max-h-[7rem] overflow-hidden pointer-events-none mix-blend-multiply dark:mix-blend-screen ${thumbState !== "ready" ? "hidden" : ""}`}
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<div className="dark:invert dark:hue-rotate-180">
<PdfThumbnail pdfUrl={pdfUrl} onLoad={onThumbLoad} onError={onThumbError} />
</div>
</div>
)}
</div>
</button>
</div>
);
}
export const GenerateResumeToolUI = ({
result,
status,
}: ToolCallMessagePartProps<GenerateResumeArgs, GenerateResumeResult>) => {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const sawRunningRef = useRef(false);
if (status.type === "running" || status.type === "requires-action") {
sawRunningRef.current = true;
}
if (status.type === "running" || status.type === "requires-action") {
return <ResumeGeneratingState />;
}
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return <ResumeCancelledState />;
}
if (status.reason === "error") {
return (
<ResumeErrorState
title="Resume"
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
if (!result) {
return <ResumeGeneratingState />;
}
if (result.status === "failed") {
return (
<ResumeErrorState
title={result.title || "Resume"}
error={
result.error || "Resume generation failed. Please try again or rephrase your request."
}
/>
);
}
if (result.status === "ready" && result.report_id) {
return (
<ResumeCard
reportId={result.report_id}
title={result.title || "Resume"}
shareToken={shareToken}
autoOpen={sawRunningRef.current}
/>
);
}
return <ResumeErrorState title="Resume" error="Missing report ID" />;
};

View file

@ -110,6 +110,7 @@
"next": "^16.1.0",
"next-intl": "^4.6.1",
"next-themes": "^0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "^8.16.3",
"platejs": "^52.0.17",
"postgres": "^3.4.7",

View file

@ -275,6 +275,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pdfjs-dist:
specifier: ^5.6.205
version: 5.6.205
pg:
specifier: ^8.16.3
version: 8.18.0
@ -1981,6 +1984,76 @@ packages:
peerDependencies:
mediabunny: ^1.0.0
'@napi-rs/canvas-android-arm64@0.1.97':
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.97':
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.97':
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.97':
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.97':
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@ -7027,6 +7100,9 @@ packages:
encoding:
optional: true
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@ -7168,6 +7244,10 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
pdfjs-dist@5.6.205:
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
@ -9992,6 +10072,54 @@ snapshots:
dependencies:
mediabunny: 1.39.2
'@napi-rs/canvas-android-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.97':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
optional: true
'@napi-rs/canvas@0.1.97':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.97
'@napi-rs/canvas-darwin-arm64': 0.1.97
'@napi-rs/canvas-darwin-x64': 0.1.97
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-musl': 0.1.97
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
optional: true
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@ -15830,6 +15958,9 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
node-readable-to-web-readable-stream@0.4.2:
optional: true
node-releases@2.0.27: {}
npm-run-path@4.0.1:
@ -15992,6 +16123,11 @@ snapshots:
path-type@4.0.0: {}
pdfjs-dist@5.6.205:
optionalDependencies:
'@napi-rs/canvas': 0.1.97
node-readable-to-web-readable-stream: 0.4.2
performance-now@2.1.0: {}
pg-cloudflare@1.3.0: