diff --git a/surfsense_backend/app/agents/new_chat/tools/report.py b/surfsense_backend/app/agents/new_chat/tools/report.py index d2cadb94e..eb104f3b8 100644 --- a/surfsense_backend/app/agents/new_chat/tools/report.py +++ b/surfsense_backend/app/agents/new_chat/tools/report.py @@ -51,11 +51,8 @@ Write the report now: def _extract_metadata(content: str) -> dict[str, Any]: """Extract metadata from generated Markdown content.""" - # Extract section headings + # Count section headings headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE) - sections = [ - {"level": len(h[0]), "title": h[1].strip()} for h in headings - ] # Word count word_count = len(content.split()) @@ -64,10 +61,10 @@ def _extract_metadata(content: str) -> dict[str, Any]: char_count = len(content) return { - "sections": sections, + "status": "ready", "word_count": word_count, "char_count": char_count, - "section_count": len(sections), + "section_count": len(headings), } @@ -110,7 +107,7 @@ def create_generate_report_tool( - "Summarize this into a report" Args: - topic: The main topic or title of the report. + topic: A short, concise title for the report (maximum 8 words). Keep it brief and descriptive — e.g. "AI in Healthcare Analysis: A Comprehensive Report" instead of "Comprehensive Analysis of Artificial Intelligence Applications in Modern Healthcare Systems". source_content: The text content to base the report on. This MUST be comprehensive and include: * If discussing the current conversation: a detailed summary of the FULL chat history * If based on knowledge base search: the key findings and insights from search results @@ -127,14 +124,41 @@ def create_generate_report_tool( - word_count: Number of words in the report - message: Status message (or "error" field if failed) """ + async def _save_failed_report(error_msg: str) -> int | None: + """Persist a failed report row so the error is visible later.""" + try: + failed_report = Report( + title=topic, + content=None, + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + ) + db_session.add(failed_report) + await db_session.commit() + await db_session.refresh(failed_report) + logger.info( + f"[generate_report] Saved failed report {failed_report.id}: {error_msg}" + ) + return failed_report.id + except Exception: + logger.exception("[generate_report] Could not persist failed report row") + return None + try: # Get the LLM instance for this search space llm = await get_document_summary_llm(db_session, search_space_id) if not llm: + error_msg = "No LLM configured. Please configure a language model in Settings." + report_id = await _save_failed_report(error_msg) return { "status": "failed", - "error": "No LLM configured. Please configure a language model in Settings.", - "report_id": None, + "error": error_msg, + "report_id": report_id, "title": topic, } @@ -159,14 +183,16 @@ def create_generate_report_tool( report_content = response.content if not report_content or not isinstance(report_content, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) return { "status": "failed", - "error": "LLM returned empty or invalid content", - "report_id": None, + "error": error_msg, + "report_id": report_id, "title": topic, } - # Extract metadata + # Extract metadata (includes "status": "ready") metadata = _extract_metadata(report_content) # Save to database @@ -199,11 +225,12 @@ def create_generate_report_tool( except Exception as e: error_message = str(e) logger.exception(f"[generate_report] Error: {error_message}") + report_id = await _save_failed_report(error_message) return { "status": "failed", "error": error_message, - "report_id": None, + "report_id": report_id, "title": topic, } diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 2755117ca..b079e4eb5 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -35,14 +35,8 @@ const ReportContentResponseSchema = z.object({ content: z.string().nullish(), report_metadata: z .object({ - sections: z - .array( - z.object({ - level: z.number(), - title: z.string(), - }) - ) - .nullish(), + status: z.enum(["ready", "failed"]).nullish(), + error_message: z.string().nullish(), word_count: z.number().nullish(), char_count: z.number().nullish(), section_count: z.number().nullish(), @@ -126,7 +120,15 @@ function ReportPanelContent({ if (cancelled) return; const parsed = ReportContentResponseSchema.safeParse(rawData); if (parsed.success) { - setReportContent(parsed.data); + // Check if the 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 { + setReportContent(parsed.data); + } } else { console.warn( "Invalid report content response:", diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index 705219b65..09aceb4c0 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -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; type GenerateReportResult = z.infer; -/** - * 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 ( -
- ); -} - /** * 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 (
- {/* Header */} -
+
+
+ +

{topic}

-
- - Writing report… -
+
- - {/* Skeleton body – simulates paragraphs being written */} -
- {/* "Heading" */} - - - {/* Paragraph 1 */} -
- - - - -
- - {/* "Heading 2" */} - - - {/* Paragraph 2 */} -
- - - -
- - {/* Bottom fade-out */} -
-
); } @@ -125,7 +80,7 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
- +

@@ -161,12 +116,14 @@ function ReportCard({ sectionCount: number | null; }>({ title, wordCount: wordCount ?? null, sectionCount: null }); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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( `/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 ; + } + const isActive = panelState.isOpen && panelState.reportId === reportId; const handleOpen = () => {