diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index b5f523429..865a83629 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -20,6 +20,7 @@ from app.services.public_chat_service import ( clone_from_snapshot, get_public_chat, get_snapshot_podcast, + get_snapshot_report, ) from app.users import current_active_user @@ -114,3 +115,28 @@ async def stream_public_podcast( "Content-Disposition": f"inline; filename={os.path.basename(file_path)}", }, ) + + +@router.get("/{share_token}/reports/{report_id}/content") +async def get_public_report_content( + share_token: str, + report_id: int, + session: AsyncSession = Depends(get_async_session), +): + """ + Get report content from a public chat snapshot. + + No authentication required - the share_token provides access. + Returns report content including title, markdown body, and metadata. + """ + report_info = await get_snapshot_report(session, share_token, report_id) + + if not report_info: + raise HTTPException(status_code=404, detail="Report not found") + + return { + "id": report_info.get("original_id"), + "title": report_info.get("title"), + "content": report_info.get("content"), + "report_metadata": report_info.get("report_metadata"), + } diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index ba2dd0079..cb8fb9830 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -29,6 +29,7 @@ from app.db import ( Podcast, PodcastStatus, PublicChatSnapshot, + Report, SearchSpaceMembership, User, ) @@ -38,6 +39,7 @@ UI_TOOLS = { "display_image", "link_preview", "generate_podcast", + "generate_report", "scrape_webpage", "multi_link_preview", } @@ -195,19 +197,22 @@ async def create_snapshot( message_ids = [] podcasts_data = [] podcast_ids_seen: set[int] = set() + reports_data = [] + report_ids_seen: set[int] = set() for msg in sorted(thread.messages, key=lambda m: m.created_at): author = await get_author_display(session, msg.author_id, user_cache) sanitized_content = sanitize_content_for_public(msg.content) - # Extract podcast references and update status to "ready" for completed podcasts + # Extract podcast/report references and update status to "ready" for completed ones if isinstance(sanitized_content, list): for part in sanitized_content: - if ( - isinstance(part, dict) - and part.get("type") == "tool-call" - and part.get("toolName") == "generate_podcast" - ): + if not isinstance(part, dict) or part.get("type") != "tool-call": + continue + + tool_name = part.get("toolName") + + if tool_name == "generate_podcast": result_data = part.get("result", {}) podcast_id = result_data.get("podcast_id") if podcast_id and podcast_id not in podcast_ids_seen: @@ -220,6 +225,19 @@ async def create_snapshot( # Update status to "ready" so frontend renders PodcastPlayer part["result"] = {**result_data, "status": "ready"} + elif tool_name == "generate_report": + 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 + ) + if report_info: + reports_data.append(report_info) + report_ids_seen.add(report_id) + # Update status to "ready" so frontend renders ReportCard + part["result"] = {**result_data, "status": "ready"} + messages_data.append( { "id": msg.id, @@ -266,6 +284,7 @@ async def create_snapshot( "author": thread_author, "messages": messages_data, "podcasts": podcasts_data, + "reports": reports_data, } # Create new snapshot @@ -309,6 +328,25 @@ async def _get_podcast_for_snapshot( } +async def _get_report_for_snapshot( + session: AsyncSession, + report_id: int, +) -> dict | None: + """Get report info for embedding in snapshot_data.""" + result = await session.execute(select(Report).filter(Report.id == report_id)) + report = result.scalars().first() + + if not report: + return None + + return { + "original_id": report.id, + "title": report.title, + "content": report.content, + "report_metadata": report.report_metadata, + } + + # ============================================================================= # Snapshot Retrieval # ============================================================================= @@ -578,6 +616,7 @@ async def clone_from_snapshot( data = snapshot.snapshot_data messages_data = data.get("messages", []) podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])} + reports_lookup = {r.get("original_id"): r for r in data.get("reports", [])} new_thread = NewChatThread( title=data.get("title", "Cloned Chat"), @@ -594,6 +633,7 @@ async def clone_from_snapshot( await session.flush() podcast_id_mapping: dict[int, int] = {} + report_id_mapping: dict[int, int] = {} # Check which authors from snapshot still exist in DB author_ids_from_snapshot: set[UUID] = set() @@ -655,6 +695,34 @@ async def clone_from_snapshot( "podcast_id": podcast_id_mapping[old_podcast_id], } + if ( + isinstance(part, dict) + and part.get("type") == "tool-call" + and part.get("toolName") == "generate_report" + ): + result = part.get("result", {}) + old_report_id = result.get("report_id") + + if old_report_id and old_report_id not in report_id_mapping: + report_info = reports_lookup.get(old_report_id) + if report_info: + new_report = Report( + title=report_info.get("title", "Cloned Report"), + content=report_info.get("content"), + report_metadata=report_info.get("report_metadata"), + search_space_id=target_search_space_id, + thread_id=new_thread.id, + ) + session.add(new_report) + await session.flush() + report_id_mapping[old_report_id] = new_report.id + + if old_report_id and old_report_id in report_id_mapping: + part["result"] = { + **result, + "report_id": report_id_mapping[old_report_id], + } + new_message = NewChatMessage( thread_id=new_thread.id, role=role, @@ -696,3 +764,29 @@ async def get_snapshot_podcast( return podcast return None + + +async def get_snapshot_report( + session: AsyncSession, + share_token: str, + report_id: int, +) -> dict | None: + """ + Get report info from a snapshot by original report ID. + + Used for displaying report content in public view. + Looks up the report by its original_id in the snapshot's reports array. + """ + snapshot = await get_snapshot_by_token(session, share_token) + + if not snapshot: + return None + + reports = snapshot.snapshot_data.get("reports", []) + + # Find report by original_id + for report in reports: + if report.get("original_id") == report_id: + return report + + return None diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts index 6ed622be7..61860912c 100644 --- a/surfsense_web/atoms/chat/report-panel.atom.ts +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -5,6 +5,8 @@ interface ReportPanelState { reportId: number | null; title: string | null; wordCount: number | null; + /** When set, uses public endpoints for fetching report data (public shared chat) */ + shareToken: string | null; } const initialState: ReportPanelState = { @@ -12,6 +14,7 @@ const initialState: ReportPanelState = { reportId: null, title: null, wordCount: null, + shareToken: null, }; /** Core atom holding the report panel state */ @@ -30,13 +33,15 @@ export const openReportPanelAtom = atom( reportId, title, wordCount, - }: { reportId: number; title: string; wordCount?: number } + shareToken, + }: { reportId: number; title: string; wordCount?: number; shareToken?: string | null } ) => { set(reportPanelAtom, { isOpen: true, reportId, title, wordCount: wordCount ?? null, + shareToken: shareToken ?? null, }); } ); diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 730198832..0d64da1bf 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -101,12 +101,15 @@ function ReportPanelContent({ title, onClose, insideDrawer = false, + shareToken, }: { reportId: number; title: string; onClose?: () => void; /** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */ insideDrawer?: boolean; + /** When set, uses public endpoint for fetching report data (public shared chat) */ + shareToken?: string | null; }) { const [reportContent, setReportContent] = useState(null); @@ -122,9 +125,10 @@ function ReportPanelContent({ setIsLoading(true); setError(null); try { - const rawData = await baseApiService.get( - `/api/v1/reports/${reportId}/content` - ); + const url = shareToken + ? `/api/v1/public/${shareToken}/reports/${reportId}/content` + : `/api/v1/reports/${reportId}/content`; + const rawData = await baseApiService.get(url); if (cancelled) return; const parsed = ReportContentResponseSchema.safeParse(rawData); if (parsed.success) { @@ -159,7 +163,7 @@ function ReportPanelContent({ return () => { cancelled = true; }; - }, [reportId]); + }, [reportId, shareToken]); // Copy markdown content const handleCopy = useCallback(async () => { @@ -274,34 +278,40 @@ function ReportPanelContent({ Download options - - handleExport("md")}> - - Download Markdown - - handleExport("pdf")} - disabled={exporting !== null} - > - {exporting === "pdf" ? ( - - ) : ( - - )} - Download PDF - - handleExport("docx")} - disabled={exporting !== null} - > - {exporting === "docx" ? ( - - ) : ( - - )} - Download DOCX - - + + 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 + + + )} + {onClose && ( @@ -363,6 +373,7 @@ function DesktopReportPanel() { reportId={panelState.reportId} title={panelState.title || "Report"} onClose={closePanel} + shareToken={panelState.shareToken} /> ); @@ -395,6 +406,7 @@ function MobileReportDrawer() { reportId={panelState.reportId} title={panelState.title || "Report"} insideDrawer + shareToken={panelState.shareToken} /> diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index 1e079ce56..40942aa13 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -3,6 +3,7 @@ import { makeAssistantToolUI } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; import { FileTextIcon } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { z } from "zod"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; @@ -103,10 +104,13 @@ function ReportCard({ reportId, title, wordCount, + shareToken, }: { reportId: number; title: string; wordCount?: number; + /** When set, uses public endpoint for fetching report data */ + shareToken?: string | null; }) { const openPanel = useSetAtom(openReportPanelAtom); const panelState = useAtomValue(reportPanelAtom); @@ -124,9 +128,10 @@ function ReportCard({ setIsLoading(true); setError(null); try { - const rawData = await baseApiService.get( - `/api/v1/reports/${reportId}/content` - ); + const url = shareToken + ? `/api/v1/public/${shareToken}/reports/${reportId}/content` + : `/api/v1/reports/${reportId}/content`; + const rawData = await baseApiService.get(url); if (cancelled) return; const parsed = ReportMetadataResponseSchema.safeParse(rawData); if (parsed.success) { @@ -154,7 +159,7 @@ function ReportCard({ return () => { cancelled = true; }; - }, [reportId, title, wordCount]); + }, [reportId, title, wordCount, shareToken]); // Show non-clickable error card for any error (failed status, not found, etc.) if (!isLoading && error) { @@ -168,6 +173,7 @@ function ReportCard({ reportId, title: metadata.title, wordCount: metadata.wordCount ?? undefined, + shareToken, }); }; @@ -218,6 +224,11 @@ export const GenerateReportToolUI = makeAssistantToolUI< >({ toolName: "generate_report", render: function GenerateReportUI({ args, result, status }) { + const params = useParams(); + const pathname = usePathname(); + const isPublicRoute = pathname?.startsWith("/public/"); + const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null; + const topic = args.topic || "Report"; // Loading state - tool is still running (LLM generating report) @@ -264,6 +275,7 @@ export const GenerateReportToolUI = makeAssistantToolUI< reportId={result.report_id} title={result.title || topic} wordCount={result.word_count ?? undefined} + shareToken={shareToken} /> ); }