diff --git a/surfsense_backend/alembic/versions/100_add_report_group_id.py b/surfsense_backend/alembic/versions/100_add_report_group_id.py index cfcbb0635..e99ab0c83 100644 --- a/surfsense_backend/alembic/versions/100_add_report_group_id.py +++ b/surfsense_backend/alembic/versions/100_add_report_group_id.py @@ -67,4 +67,3 @@ def downgrade() -> None: END $$; """ ) - diff --git a/surfsense_backend/alembic/versions/99_add_reports_table.py b/surfsense_backend/alembic/versions/99_add_reports_table.py index 959693251..663e6869b 100644 --- a/surfsense_backend/alembic/versions/99_add_reports_table.py +++ b/surfsense_backend/alembic/versions/99_add_reports_table.py @@ -64,4 +64,3 @@ def downgrade() -> None: op.execute("DROP INDEX IF EXISTS ix_reports_thread_id") op.execute("DROP INDEX IF EXISTS ix_reports_search_space_id") op.execute("DROP TABLE IF EXISTS reports") - diff --git a/surfsense_backend/app/agents/new_chat/tools/report.py b/surfsense_backend/app/agents/new_chat/tools/report.py index 04ee27ef0..c5a8af929 100644 --- a/surfsense_backend/app/agents/new_chat/tools/report.py +++ b/surfsense_backend/app/agents/new_chat/tools/report.py @@ -212,14 +212,18 @@ def create_generate_report_tool( ) return failed_report.id except Exception: - logger.exception("[generate_report] Could not persist failed report row") + 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." + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) report_id = await _save_failed_report(error_msg) return { "status": "failed", diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 864eb7980..e232f0e14 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1039,7 +1039,9 @@ class Report(BaseModel, TimestampMixin): title = Column(String(500), nullable=False) content = Column(Text, nullable=True) # Markdown body report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc. - report_style = Column(String(100), nullable=True) # e.g. "executive_summary", "deep_research" + report_style = Column( + String(100), nullable=True + ) # e.g. "executive_summary", "deep_research" search_space_id = Column( Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 3e949c687..db4e81026 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -32,9 +32,9 @@ from .notes_routes import router as notes_router from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router -from .reports_routes import router as reports_router from .public_chat_routes import router as public_chat_router from .rbac_routes import router as rbac_router +from .reports_routes import router as reports_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index 39ff320f5..08c8e040b 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -51,6 +51,7 @@ class ExportFormat(str, Enum): # Helpers # --------------------------------------------------------------------------- + async def _get_report_with_access( report_id: int, session: AsyncSession, @@ -66,7 +67,7 @@ async def _get_report_with_access( if not report: raise HTTPException(status_code=404, detail="Report not found") - # Lightweight membership check – no granular RBAC, just "is the user a + # Lightweight membership check - no granular RBAC, just "is the user a # member of the search space this report belongs to?" await check_search_space_access(session, user, report.search_space_id) @@ -191,7 +192,9 @@ async def read_report_content( @router.get("/reports/{report_id}/export") async def export_report( report_id: int, - format: ExportFormat = Query(ExportFormat.PDF, description="Export format: pdf or docx"), + format: ExportFormat = Query( + ExportFormat.PDF, description="Export format: pdf or docx" + ), session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -243,8 +246,9 @@ async def export_report( # Sanitize filename safe_title = ( - "".join(c if c.isalnum() or c in " -_" else "_" for c in report.title) - .strip()[:80] + "".join( + c if c.isalnum() or c in " -_" else "_" for c in report.title + ).strip()[:80] or "report" ) @@ -265,9 +269,7 @@ async def export_report( raise except Exception as e: logger.exception("Report export failed") - raise HTTPException( - status_code=500, detail=f"Export failed: {e!s}" - ) from e + raise HTTPException(status_code=500, detail=f"Export failed: {e!s}") from e @router.delete("/reports/{report_id}", response_model=dict) diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 05c069a01..e3f03b29c 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -59,7 +59,6 @@ from .new_llm_config import ( NewLLMConfigUpdate, ) from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate -from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo from .rbac_schemas import ( InviteAcceptRequest, InviteAcceptResponse, @@ -77,6 +76,7 @@ from .rbac_schemas import ( RoleUpdate, UserSearchSpaceAccess, ) +from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo from .search_source_connector import ( MCPConnectorCreate, MCPConnectorRead, diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 9c4ed0c7c..a74d45991 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -229,9 +229,7 @@ async def create_snapshot( result_data = part.get("result", {}) report_id = result_data.get("report_id") if report_id and report_id not in report_ids_seen: - report_info = await _get_report_for_snapshot( - session, report_id - ) + report_info = await _get_report_for_snapshot(session, report_id) if report_info: reports_data.append(report_info) report_ids_seen.add(report_id) @@ -816,9 +814,7 @@ async def get_snapshot_report_versions( return [] reports = snapshot.snapshot_data.get("reports", []) - siblings = [ - r for r in reports if r.get("report_group_id") == report_group_id - ] + siblings = [r for r in reports if r.get("report_group_id") == report_group_id] # Sort by original_id (ascending = insertion order ≈ created_at order) siblings.sort(key=lambda r: r.get("original_id", 0)) diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index 61860912c..0abb0c0ff 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -50,4 +50,3 @@ export const openReportPanelAtom = atom( export const closeReportPanelAtom = atom(null, (_, set) => { set(reportPanelAtom, initialState); }); - diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 1b90cf902..192b0f0ba 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -6,7 +6,7 @@ import "katex/dist/katex.min.css"; import { cn } from "@/lib/utils"; const code = createCodePlugin({ - themes: ["nord", "nord"] + themes: ["nord", "nord"], }); const math = createMathPlugin({ @@ -24,9 +24,7 @@ interface MarkdownViewerProps { */ function stripOuterMarkdownFence(content: string): string { const trimmed = content.trim(); - const match = trimmed.match( - /^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/ - ); + const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/); return match ? match[1] : content; } @@ -121,8 +119,18 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) { ), - th: ({ ...props }) =>
, - td: ({ ...props }) => , + th: ({ ...props }) => ( + + ), + td: ({ ...props }) => ( + + ), }; return ( diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 9a358091d..87db105d2 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -1,23 +1,13 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { - ChevronDownIcon, - XIcon, -} from "lucide-react"; +import { ChevronDownIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; -import { - closeReportPanelAtom, - reportPanelAtom, -} from "@/atoms/chat/report-panel.atom"; +import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import { - Drawer, - DrawerContent, - DrawerHandle, -} from "@/components/ui/drawer"; +import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, @@ -118,8 +108,7 @@ function ReportPanelContent({ /** When set, uses public endpoint for fetching report data (public shared chat) */ shareToken?: string | null; }) { - const [reportContent, setReportContent] = - useState(null); + const [reportContent, setReportContent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -150,10 +139,7 @@ function ReportPanelContent({ if (parsed.success) { // 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" - ); + setError(parsed.data.report_metadata?.error_message || "Report generation failed"); } else { setReportContent(parsed.data); // Update versions from the response @@ -162,18 +148,13 @@ function ReportPanelContent({ } } } else { - console.warn( - "Invalid report content response:", - parsed.error.issues - ); + console.warn("Invalid report content response:", parsed.error.issues); setError("Invalid response format"); } } catch (err) { if (cancelled) return; console.error("Error fetching report content:", err); - setError( - err instanceof Error ? err.message : "Failed to load report" - ); + setError(err instanceof Error ? err.message : "Failed to load report"); } finally { if (!cancelled) setIsLoading(false); } @@ -202,8 +183,10 @@ function ReportPanelContent({ async (format: "pdf" | "docx" | "md") => { setExporting(format); const safeTitle = - title.replace(/[^a-zA-Z0-9 _-]/g, "_").trim().slice(0, 80) || - "report"; + title + .replace(/[^a-zA-Z0-9 _-]/g, "_") + .trim() + .slice(0, 80) || "report"; try { if (format === "md") { // Download markdown content directly as a .md file @@ -248,7 +231,6 @@ function ReportPanelContent({ [activeReportId, title, reportContent?.content] ); - // Show full-page skeleton only on initial load (no data loaded yet). // Once we have versions/content from a prior fetch, keep the action bar visible. const hasLoadedBefore = versions.length > 0 || reportContent !== null; @@ -259,12 +241,7 @@ function ReportPanelContent({ {/* Minimal top bar with close button even during initial load */}
{onClose && ( - @@ -282,64 +259,63 @@ function ReportPanelContent({ {/* Action bar — always visible after initial load */}
- {/* Copy button */} - + {/* Copy button */} + - {/* Export dropdown */} - - - + + - Export - - - - - handleExport("md")}> - Download Markdown - - {/* PDF/DOCX export requires server-side conversion via authenticated endpoint. + handleExport("md")}> + Download Markdown + + {/* PDF/DOCX export requires server-side conversion via authenticated endpoint. Hide for public viewers who have no auth token. */} - {!shareToken && ( - <> - handleExport("pdf")} - disabled={exporting !== null} - > - {exporting === "pdf" && ( - - )} - Download PDF - - handleExport("docx")} - disabled={exporting !== null} - > - {exporting === "docx" && ( - - )} - Download DOCX - - - )} - - + {!shareToken && ( + <> + handleExport("pdf")} + disabled={exporting !== null} + > + {exporting === "pdf" && } + Download PDF + + handleExport("docx")} + disabled={exporting !== null} + > + {exporting === "docx" && } + Download DOCX + + + )} + + {/* Version switcher — only shown when multiple versions exist */} - {versions.length > 1 && ( - insideDrawer ? ( + {versions.length > 1 && + (insideDrawer ? ( /* Mobile: compact dropdown */ @@ -387,16 +363,10 @@ function ReportPanelContent({ {activeVersionIndex + 1} of {versions.length}
- ) - )} + ))}
{onClose && ( - @@ -411,9 +381,7 @@ function ReportPanelContent({

Failed to load report

-

- {error || "An unknown error occurred"} -

+

{error || "An unknown error occurred"}

) : ( @@ -421,9 +389,7 @@ function ReportPanelContent({ {reportContent.content ? ( ) : ( -

- No content available. -

+

No content available.

)}
)} @@ -485,10 +451,7 @@ function MobileReportDrawer() { }} shouldScaleBackground={false} > - +
{description}

+

+ {description} +

)} {/* Metadata row */} @@ -305,7 +307,6 @@ export function Article({ )}
- {/* Response actions */} diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index 5edc7e091..b1d9b24b3 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -7,10 +7,7 @@ import { useParams, usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { z } from "zod"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; -import { - openReportPanelAtom, - reportPanelAtom, -} from "@/atoms/chat/report-panel.atom"; +import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { baseApiService } from "@/lib/apis/base-api.service"; /** @@ -97,9 +94,7 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {

{title}

-

- {error} -

+

{error}

@@ -148,10 +143,7 @@ function ReportCard({ if (parsed.success) { // 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" - ); + setError(parsed.data.report_metadata?.error_message || "Report generation failed"); } else { // Determine version label from versions array let versionLabel: string | null = null; @@ -164,8 +156,7 @@ function ReportCard({ } setMetadata({ title: parsed.data.title || title, - wordCount: - parsed.data.report_metadata?.word_count ?? wordCount ?? null, + wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null, versionLabel, }); } @@ -219,9 +210,10 @@ function ReportCard({ ) : ( <> - {metadata.wordCount != null && - `${metadata.wordCount.toLocaleString()} words`} - {metadata.wordCount != null && metadata.versionLabel && } + {metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`} + {metadata.wordCount != null && metadata.versionLabel && ( + + )} {metadata.versionLabel} )} @@ -241,10 +233,7 @@ function ReportCard({ * Unlike podcast (which uses polling), the report is generated inline * and the result contains status: "ready" immediately. */ -export const GenerateReportToolUI = makeAssistantToolUI< - GenerateReportArgs, - GenerateReportResult ->({ +export const GenerateReportToolUI = makeAssistantToolUI({ toolName: "generate_report", render: function GenerateReportUI({ args, result, status }) { const params = useParams(); @@ -288,7 +277,12 @@ export const GenerateReportToolUI = makeAssistantToolUI< // Failed result if (result.status === "failed") { - return ; + return ( + + ); } // Ready with report_id diff --git a/surfsense_web/components/tool-ui/scrape-webpage.tsx b/surfsense_web/components/tool-ui/scrape-webpage.tsx index 17cfb218b..87fae8868 100644 --- a/surfsense_web/components/tool-ui/scrape-webpage.tsx +++ b/surfsense_web/components/tool-ui/scrape-webpage.tsx @@ -89,12 +89,7 @@ function ScrapeCancelledState({ url }: { url: string }) { function ParsedArticle({ result }: { result: unknown }) { const { description, ...article } = parseSerializableArticle(result); - return ( -
- ); + return
; } /**