feat: enhance report generation with error handling and metadata extraction

- Updated the report generation tool to include error handling for failed report generation, persisting error messages in the database.
- Modified metadata extraction to count section headings and return a status indicating readiness or failure.
- Adjusted frontend components to handle and display error states based on report generation outcomes, improving user feedback and experience.
This commit is contained in:
Anish Sarkar 2026-02-11 20:04:36 +05:30
parent e4244829ae
commit 90bf93b0a5
3 changed files with 85 additions and 86 deletions

View file

@ -2,12 +2,10 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import {
FileTextIcon,
Loader2Icon,
} from "lucide-react";
import { FileTextIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { z } from "zod";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import {
openReportPanelAtom,
reportPanelAtom,
@ -38,6 +36,8 @@ const ReportMetadataResponseSchema = z.object({
title: z.string(),
report_metadata: z
.object({
status: z.enum(["ready", "failed"]).nullish(),
error_message: z.string().nullish(),
word_count: z.number().nullish(),
section_count: z.number().nullish(),
})
@ -50,69 +50,24 @@ const ReportMetadataResponseSchema = z.object({
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
/**
* Shimmer line used in the skeleton loading state.
* Each line has a staggered delay and random-ish width to mimic real paragraphs.
*/
function ShimmerLine({ width, delay }: { width: string; delay: string }) {
return (
<div
className="h-2.5 rounded-md bg-muted/60"
style={{
width,
animation: `pulse 2s cubic-bezier(0.4,0,0.6,1) infinite`,
animationDelay: delay,
}}
/>
);
}
/**
* Loading state component shown while report is being generated.
* Renders a card with a skeleton that looks like report content being written.
* Matches the compact card layout of the completed ReportCard.
*/
function ReportGeneratingState({ topic }: { topic: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
{/* Header */}
<div className="flex items-center gap-2 sm:gap-3 border-b bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight truncate">
{topic}
</h3>
<div className="mt-1 flex items-center gap-1.5 text-muted-foreground">
<Loader2Icon className="size-3 animate-spin" />
<span className="text-[11px] sm:text-xs">Writing report</span>
</div>
<TextShimmerLoader text="Writing report…" size="sm" />
</div>
</div>
{/* Skeleton body simulates paragraphs being written */}
<div className="px-4 py-4 sm:px-6 sm:py-5 space-y-4 max-h-52 overflow-hidden relative">
{/* "Heading" */}
<ShimmerLine width="40%" delay="0ms" />
{/* Paragraph 1 */}
<div className="space-y-2">
<ShimmerLine width="100%" delay="100ms" />
<ShimmerLine width="92%" delay="150ms" />
<ShimmerLine width="97%" delay="200ms" />
<ShimmerLine width="60%" delay="250ms" />
</div>
{/* "Heading 2" */}
<ShimmerLine width="35%" delay="300ms" />
{/* Paragraph 2 */}
<div className="space-y-2">
<ShimmerLine width="95%" delay="350ms" />
<ShimmerLine width="100%" delay="400ms" />
<ShimmerLine width="88%" delay="450ms" />
</div>
{/* Bottom fade-out */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-card to-transparent" />
</div>
</div>
);
}
@ -125,7 +80,7 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
<div className="my-4 overflow-hidden rounded-xl border bg-card">
<div className="flex items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-muted/60">
<FileTextIcon className="size-4 sm:size-5 text-muted-foreground/50" />
<FileTextIcon className="size-4 sm:size-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight truncate">
@ -161,12 +116,14 @@ function ReportCard({
sectionCount: number | null;
}>({ title, wordCount: wordCount ?? null, sectionCount: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch lightweight metadata (title + counts only, no content)
useEffect(() => {
let cancelled = false;
const fetchMetadata = async () => {
setIsLoading(true);
setError(null);
try {
const rawData = await baseApiService.get<unknown>(
`/api/v1/reports/${reportId}/content`
@ -174,16 +131,24 @@ function ReportCard({
if (cancelled) return;
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
if (parsed.success) {
setMetadata({
title: parsed.data.title || title,
wordCount:
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
sectionCount:
parsed.data.report_metadata?.section_count ?? null,
});
// Check if report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") {
setError(
parsed.data.report_metadata?.error_message ||
"Report generation failed"
);
} else {
setMetadata({
title: parsed.data.title || title,
wordCount:
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
sectionCount:
parsed.data.report_metadata?.section_count ?? null,
});
}
}
} catch {
// Silently fail — we already have the title and word count from the tool result
if (!cancelled) setError("No report found");
} finally {
if (!cancelled) setIsLoading(false);
}
@ -194,6 +159,11 @@ function ReportCard({
};
}, [reportId, title, wordCount]);
// Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) {
return <ReportErrorState title={title} error={error} />;
}
const isActive = panelState.isOpen && panelState.reportId === reportId;
const handleOpen = () => {