feat: add public report content retrieval and enhance report handling

This commit is contained in:
Anish Sarkar 2026-02-11 22:07:31 +05:30
parent 59628fdf76
commit e5626342fc
5 changed files with 192 additions and 43 deletions

View file

@ -20,6 +20,7 @@ from app.services.public_chat_service import (
clone_from_snapshot, clone_from_snapshot,
get_public_chat, get_public_chat,
get_snapshot_podcast, get_snapshot_podcast,
get_snapshot_report,
) )
from app.users import current_active_user 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)}", "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"),
}

View file

@ -29,6 +29,7 @@ from app.db import (
Podcast, Podcast,
PodcastStatus, PodcastStatus,
PublicChatSnapshot, PublicChatSnapshot,
Report,
SearchSpaceMembership, SearchSpaceMembership,
User, User,
) )
@ -38,6 +39,7 @@ UI_TOOLS = {
"display_image", "display_image",
"link_preview", "link_preview",
"generate_podcast", "generate_podcast",
"generate_report",
"scrape_webpage", "scrape_webpage",
"multi_link_preview", "multi_link_preview",
} }
@ -195,19 +197,22 @@ async def create_snapshot(
message_ids = [] message_ids = []
podcasts_data = [] podcasts_data = []
podcast_ids_seen: set[int] = set() 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): for msg in sorted(thread.messages, key=lambda m: m.created_at):
author = await get_author_display(session, msg.author_id, user_cache) author = await get_author_display(session, msg.author_id, user_cache)
sanitized_content = sanitize_content_for_public(msg.content) 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): if isinstance(sanitized_content, list):
for part in sanitized_content: for part in sanitized_content:
if ( if not isinstance(part, dict) or part.get("type") != "tool-call":
isinstance(part, dict) continue
and part.get("type") == "tool-call"
and part.get("toolName") == "generate_podcast" tool_name = part.get("toolName")
):
if tool_name == "generate_podcast":
result_data = part.get("result", {}) result_data = part.get("result", {})
podcast_id = result_data.get("podcast_id") podcast_id = result_data.get("podcast_id")
if podcast_id and podcast_id not in podcast_ids_seen: 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 # Update status to "ready" so frontend renders PodcastPlayer
part["result"] = {**result_data, "status": "ready"} 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( messages_data.append(
{ {
"id": msg.id, "id": msg.id,
@ -266,6 +284,7 @@ async def create_snapshot(
"author": thread_author, "author": thread_author,
"messages": messages_data, "messages": messages_data,
"podcasts": podcasts_data, "podcasts": podcasts_data,
"reports": reports_data,
} }
# Create new snapshot # 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 # Snapshot Retrieval
# ============================================================================= # =============================================================================
@ -578,6 +616,7 @@ async def clone_from_snapshot(
data = snapshot.snapshot_data data = snapshot.snapshot_data
messages_data = data.get("messages", []) messages_data = data.get("messages", [])
podcasts_lookup = {p.get("original_id"): p for p in data.get("podcasts", [])} 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( new_thread = NewChatThread(
title=data.get("title", "Cloned Chat"), title=data.get("title", "Cloned Chat"),
@ -594,6 +633,7 @@ async def clone_from_snapshot(
await session.flush() await session.flush()
podcast_id_mapping: dict[int, int] = {} podcast_id_mapping: dict[int, int] = {}
report_id_mapping: dict[int, int] = {}
# Check which authors from snapshot still exist in DB # Check which authors from snapshot still exist in DB
author_ids_from_snapshot: set[UUID] = set() author_ids_from_snapshot: set[UUID] = set()
@ -655,6 +695,34 @@ async def clone_from_snapshot(
"podcast_id": podcast_id_mapping[old_podcast_id], "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( new_message = NewChatMessage(
thread_id=new_thread.id, thread_id=new_thread.id,
role=role, role=role,
@ -696,3 +764,29 @@ async def get_snapshot_podcast(
return podcast return podcast
return None 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

View file

@ -5,6 +5,8 @@ interface ReportPanelState {
reportId: number | null; reportId: number | null;
title: string | null; title: string | null;
wordCount: number | null; wordCount: number | null;
/** When set, uses public endpoints for fetching report data (public shared chat) */
shareToken: string | null;
} }
const initialState: ReportPanelState = { const initialState: ReportPanelState = {
@ -12,6 +14,7 @@ const initialState: ReportPanelState = {
reportId: null, reportId: null,
title: null, title: null,
wordCount: null, wordCount: null,
shareToken: null,
}; };
/** Core atom holding the report panel state */ /** Core atom holding the report panel state */
@ -30,13 +33,15 @@ export const openReportPanelAtom = atom(
reportId, reportId,
title, title,
wordCount, wordCount,
}: { reportId: number; title: string; wordCount?: number } shareToken,
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
) => { ) => {
set(reportPanelAtom, { set(reportPanelAtom, {
isOpen: true, isOpen: true,
reportId, reportId,
title, title,
wordCount: wordCount ?? null, wordCount: wordCount ?? null,
shareToken: shareToken ?? null,
}); });
} }
); );

View file

@ -101,12 +101,15 @@ function ReportPanelContent({
title, title,
onClose, onClose,
insideDrawer = false, insideDrawer = false,
shareToken,
}: { }: {
reportId: number; reportId: number;
title: string; title: string;
onClose?: () => void; onClose?: () => void;
/** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */ /** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */
insideDrawer?: boolean; insideDrawer?: boolean;
/** When set, uses public endpoint for fetching report data (public shared chat) */
shareToken?: string | null;
}) { }) {
const [reportContent, setReportContent] = const [reportContent, setReportContent] =
useState<ReportContentResponse | null>(null); useState<ReportContentResponse | null>(null);
@ -122,9 +125,10 @@ function ReportPanelContent({
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const rawData = await baseApiService.get<unknown>( const url = shareToken
`/api/v1/reports/${reportId}/content` ? `/api/v1/public/${shareToken}/reports/${reportId}/content`
); : `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return; if (cancelled) return;
const parsed = ReportContentResponseSchema.safeParse(rawData); const parsed = ReportContentResponseSchema.safeParse(rawData);
if (parsed.success) { if (parsed.success) {
@ -159,7 +163,7 @@ function ReportPanelContent({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [reportId]); }, [reportId, shareToken]);
// Copy markdown content // Copy markdown content
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
@ -274,34 +278,40 @@ function ReportPanelContent({
<span className="sr-only">Download options</span> <span className="sr-only">Download options</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}> <DropdownMenuContent align="start" className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}>
<DropdownMenuItem onClick={() => handleExport("md")}> <DropdownMenuItem onClick={() => handleExport("md")}>
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
Download Markdown Download Markdown
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem {/* PDF/DOCX export requires server-side conversion via authenticated endpoint.
onClick={() => handleExport("pdf")} Hide for public viewers who have no auth token. */}
disabled={exporting !== null} {!shareToken && (
> <>
{exporting === "pdf" ? ( <DropdownMenuItem
<Loader2Icon className="size-4 animate-spin" /> onClick={() => handleExport("pdf")}
) : ( disabled={exporting !== null}
<DownloadIcon className="size-4" /> >
)} {exporting === "pdf" ? (
Download PDF <Loader2Icon className="size-4 animate-spin" />
</DropdownMenuItem> ) : (
<DropdownMenuItem <DownloadIcon className="size-4" />
onClick={() => handleExport("docx")} )}
disabled={exporting !== null} Download PDF
> </DropdownMenuItem>
{exporting === "docx" ? ( <DropdownMenuItem
<Loader2Icon className="size-4 animate-spin" /> onClick={() => handleExport("docx")}
) : ( disabled={exporting !== null}
<DownloadIcon className="size-4" /> >
)} {exporting === "docx" ? (
Download DOCX <Loader2Icon className="size-4 animate-spin" />
</DropdownMenuItem> ) : (
</DropdownMenuContent> <DownloadIcon className="size-4" />
)}
Download DOCX
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{onClose && ( {onClose && (
@ -363,6 +373,7 @@ function DesktopReportPanel() {
reportId={panelState.reportId} reportId={panelState.reportId}
title={panelState.title || "Report"} title={panelState.title || "Report"}
onClose={closePanel} onClose={closePanel}
shareToken={panelState.shareToken}
/> />
</div> </div>
); );
@ -395,6 +406,7 @@ function MobileReportDrawer() {
reportId={panelState.reportId} reportId={panelState.reportId}
title={panelState.title || "Report"} title={panelState.title || "Report"}
insideDrawer insideDrawer
shareToken={panelState.shareToken}
/> />
</div> </div>
</DrawerContent> </DrawerContent>

View file

@ -3,6 +3,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { FileTextIcon } from "lucide-react"; import { FileTextIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
@ -103,10 +104,13 @@ function ReportCard({
reportId, reportId,
title, title,
wordCount, wordCount,
shareToken,
}: { }: {
reportId: number; reportId: number;
title: string; title: string;
wordCount?: number; wordCount?: number;
/** When set, uses public endpoint for fetching report data */
shareToken?: string | null;
}) { }) {
const openPanel = useSetAtom(openReportPanelAtom); const openPanel = useSetAtom(openReportPanelAtom);
const panelState = useAtomValue(reportPanelAtom); const panelState = useAtomValue(reportPanelAtom);
@ -124,9 +128,10 @@ function ReportCard({
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const rawData = await baseApiService.get<unknown>( const url = shareToken
`/api/v1/reports/${reportId}/content` ? `/api/v1/public/${shareToken}/reports/${reportId}/content`
); : `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return; if (cancelled) return;
const parsed = ReportMetadataResponseSchema.safeParse(rawData); const parsed = ReportMetadataResponseSchema.safeParse(rawData);
if (parsed.success) { if (parsed.success) {
@ -154,7 +159,7 @@ function ReportCard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [reportId, title, wordCount]); }, [reportId, title, wordCount, shareToken]);
// Show non-clickable error card for any error (failed status, not found, etc.) // Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) { if (!isLoading && error) {
@ -168,6 +173,7 @@ function ReportCard({
reportId, reportId,
title: metadata.title, title: metadata.title,
wordCount: metadata.wordCount ?? undefined, wordCount: metadata.wordCount ?? undefined,
shareToken,
}); });
}; };
@ -218,6 +224,11 @@ export const GenerateReportToolUI = makeAssistantToolUI<
>({ >({
toolName: "generate_report", toolName: "generate_report",
render: function GenerateReportUI({ args, result, status }) { 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"; const topic = args.topic || "Report";
// Loading state - tool is still running (LLM generating report) // Loading state - tool is still running (LLM generating report)
@ -264,6 +275,7 @@ export const GenerateReportToolUI = makeAssistantToolUI<
reportId={result.report_id} reportId={result.report_id}
title={result.title || topic} title={result.title || topic}
wordCount={result.word_count ?? undefined} wordCount={result.word_count ?? undefined}
shareToken={shareToken}
/> />
); );
} }