mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
refactor: enhance report generation UI with improved loading and error states, and update report content handling
This commit is contained in:
parent
19bd0c48e8
commit
b6be92e394
1 changed files with 91 additions and 78 deletions
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Dot, FileTextIcon } from "lucide-react";
|
import { Dot } from "lucide-react";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
|
|
@ -31,9 +32,10 @@ const GenerateReportResultSchema = z.object({
|
||||||
error: z.string().nullish(),
|
error: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ReportMetadataResponseSchema = z.object({
|
const ReportContentResponseSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
content: z.string().nullish(),
|
||||||
report_metadata: z
|
report_metadata: z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(["ready", "failed"]).nullish(),
|
status: z.enum(["ready", "failed"]).nullish(),
|
||||||
|
|
@ -59,53 +61,59 @@ const ReportMetadataResponseSchema = z.object({
|
||||||
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
|
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
|
||||||
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
|
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
|
||||||
|
|
||||||
/**
|
function ContentSkeleton() {
|
||||||
* Loading state component shown while report is being generated.
|
return (
|
||||||
* Matches the compact card layout of the completed ReportCard.
|
<div className="space-y-2">
|
||||||
*/
|
<div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
|
||||||
|
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||||
|
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReportGeneratingState({ topic }: { topic: string }) {
|
function ReportGeneratingState({ topic }: { topic: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6">
|
<div className="px-5 pt-5 pb-4">
|
||||||
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<p className="text-sm font-semibold text-foreground line-clamp-2">{topic}</p>
|
||||||
<FileTextIcon className="size-4 sm:size-6 text-primary" />
|
<TextShimmerLoader text="Putting things together" size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
|
<div className="px-5 pt-3 pb-4">
|
||||||
{topic}
|
<ContentSkeleton />
|
||||||
</h3>
|
|
||||||
<TextShimmerLoader text="Putting things together" size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Error state component shown when report generation fails
|
|
||||||
*/
|
|
||||||
function ReportErrorState({ title, error }: { title: string; error: string }) {
|
function ReportErrorState({ title, error }: { title: string; error: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
<div className="flex items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6">
|
<div className="px-5 pt-5 pb-4">
|
||||||
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-muted/60">
|
<p className="text-sm font-semibold text-destructive">Report Generation Failed</p>
|
||||||
<FileTextIcon className="size-4 sm:size-6 text-muted-foreground" />
|
</div>
|
||||||
</div>
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="px-5 py-4">
|
||||||
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight line-clamp-2">
|
<p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
|
||||||
{title}
|
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-muted-foreground/60 text-[11px] sm:text-xs mt-0.5 truncate">{error}</p>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportCancelledState() {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-muted-foreground">Report Cancelled</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Report generation was cancelled
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact report card shown inline in the chat.
|
|
||||||
* Clicking it opens the report in the right-side panel (desktop) or Vaul drawer (mobile).
|
|
||||||
*/
|
|
||||||
function ReportCard({
|
function ReportCard({
|
||||||
reportId,
|
reportId,
|
||||||
title,
|
title,
|
||||||
|
|
@ -116,9 +124,7 @@ function ReportCard({
|
||||||
reportId: number;
|
reportId: number;
|
||||||
title: string;
|
title: string;
|
||||||
wordCount?: number;
|
wordCount?: number;
|
||||||
/** When set, uses public endpoint for fetching report data */
|
|
||||||
shareToken?: string | null;
|
shareToken?: string | null;
|
||||||
/** When true, auto-opens the report panel on desktop after metadata loads */
|
|
||||||
autoOpen?: boolean;
|
autoOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const openPanel = useSetAtom(openReportPanelAtom);
|
const openPanel = useSetAtom(openReportPanelAtom);
|
||||||
|
|
@ -129,14 +135,14 @@ function ReportCard({
|
||||||
title: string;
|
title: string;
|
||||||
wordCount: number | null;
|
wordCount: number | null;
|
||||||
versionLabel: string | null;
|
versionLabel: string | null;
|
||||||
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
|
content: string | null;
|
||||||
|
}>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null });
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch lightweight metadata (title + counts + version info)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchMetadata = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -145,13 +151,11 @@ function ReportCard({
|
||||||
: `/api/v1/reports/${reportId}/content`;
|
: `/api/v1/reports/${reportId}/content`;
|
||||||
const rawData = await baseApiService.get<unknown>(url);
|
const rawData = await baseApiService.get<unknown>(url);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
|
const parsed = ReportContentResponseSchema.safeParse(rawData);
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
// Check if report was marked as failed in metadata
|
|
||||||
if (parsed.data.report_metadata?.status === "failed") {
|
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 {
|
} else {
|
||||||
// Determine version label from versions array
|
|
||||||
let versionLabel: string | null = null;
|
let versionLabel: string | null = null;
|
||||||
const versions = parsed.data.versions;
|
const versions = parsed.data.versions;
|
||||||
if (versions && versions.length > 1) {
|
if (versions && versions.length > 1) {
|
||||||
|
|
@ -162,7 +166,12 @@ function ReportCard({
|
||||||
}
|
}
|
||||||
const resolvedTitle = parsed.data.title || title;
|
const resolvedTitle = parsed.data.title || title;
|
||||||
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
|
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
|
||||||
setMetadata({ title: resolvedTitle, wordCount: resolvedWordCount, versionLabel });
|
setMetadata({
|
||||||
|
title: resolvedTitle,
|
||||||
|
wordCount: resolvedWordCount,
|
||||||
|
versionLabel,
|
||||||
|
content: parsed.data.content ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
||||||
autoOpenedRef.current = true;
|
autoOpenedRef.current = true;
|
||||||
|
|
@ -181,13 +190,12 @@ function ReportCard({
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchMetadata();
|
fetchData();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]);
|
}, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]);
|
||||||
|
|
||||||
// Show non-clickable error card for any error (failed status, not found, etc.)
|
|
||||||
if (!isLoading && error) {
|
if (!isLoading && error) {
|
||||||
return <ReportErrorState title={title} error={error} />;
|
return <ReportErrorState title={title} error={error} />;
|
||||||
}
|
}
|
||||||
|
|
@ -205,26 +213,24 @@ function ReportCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="px-5 pt-5 pb-4 select-none">
|
||||||
<FileTextIcon className="size-4 sm:size-6 text-primary" />
|
<p className="text-sm font-semibold text-foreground line-clamp-2">
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
|
|
||||||
{isLoading ? title : metadata.title}
|
{isLoading ? title : metadata.title}
|
||||||
</h3>
|
</p>
|
||||||
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
|
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
|
{metadata.wordCount != null &&
|
||||||
|
`${metadata.wordCount.toLocaleString()} words`}
|
||||||
{metadata.wordCount != null && metadata.versionLabel && (
|
{metadata.wordCount != null && metadata.versionLabel && (
|
||||||
<Dot className="inline size-4" />
|
<Dot className="inline size-4" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -233,19 +239,41 @@ function ReportCard({
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
|
||||||
|
<div className="px-5 pt-3 pb-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<ContentSkeleton />
|
||||||
|
) : metadata.content ? (
|
||||||
|
<div
|
||||||
|
className="max-h-[7rem] overflow-hidden [&_*]:!text-[24px]"
|
||||||
|
style={{
|
||||||
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlateEditor
|
||||||
|
markdown={metadata.content}
|
||||||
|
readOnly
|
||||||
|
preset="readonly"
|
||||||
|
editorVariant="none"
|
||||||
|
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No content available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Report Tool UI Component
|
* Generate Report Tool UI — renders custom UI inline in chat
|
||||||
*
|
|
||||||
* This component is registered with assistant-ui to render custom UI
|
|
||||||
* when the generate_report tool is called by the agent.
|
* when the generate_report tool is called by the agent.
|
||||||
*
|
|
||||||
* 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<GenerateReportArgs, GenerateReportResult>({
|
||||||
toolName: "generate_report",
|
toolName: "generate_report",
|
||||||
|
|
@ -257,29 +285,18 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
|
|
||||||
const topic = args.topic || "Report";
|
const topic = args.topic || "Report";
|
||||||
|
|
||||||
// Track whether we witnessed the generation (running state).
|
|
||||||
// If we mount directly with a result, this stays false → it's a revisit.
|
|
||||||
const sawRunningRef = useRef(false);
|
const sawRunningRef = useRef(false);
|
||||||
if (status.type === "running" || status.type === "requires-action") {
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
sawRunningRef.current = true;
|
sawRunningRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - tool is still running (LLM generating report)
|
|
||||||
if (status.type === "running" || status.type === "requires-action") {
|
if (status.type === "running" || status.type === "requires-action") {
|
||||||
return <ReportGeneratingState topic={topic} />;
|
return <ReportGeneratingState topic={topic} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incomplete/cancelled state
|
|
||||||
if (status.type === "incomplete") {
|
if (status.type === "incomplete") {
|
||||||
if (status.reason === "cancelled") {
|
if (status.reason === "cancelled") {
|
||||||
return (
|
return <ReportCancelledState />;
|
||||||
<div className="my-4 rounded-xl border border-muted p-3 sm:p-4 text-muted-foreground">
|
|
||||||
<p className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
|
|
||||||
<FileTextIcon className="size-3.5 sm:size-4" />
|
|
||||||
<span className="line-through">Report generation cancelled</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (status.reason === "error") {
|
if (status.reason === "error") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -291,12 +308,10 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No result yet
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return <ReportGeneratingState topic={topic} />;
|
return <ReportGeneratingState topic={topic} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failed result
|
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return (
|
return (
|
||||||
<ReportErrorState
|
<ReportErrorState
|
||||||
|
|
@ -306,7 +321,6 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ready with report_id
|
|
||||||
if (result.status === "ready" && result.report_id) {
|
if (result.status === "ready" && result.report_id) {
|
||||||
return (
|
return (
|
||||||
<ReportCard
|
<ReportCard
|
||||||
|
|
@ -319,7 +333,6 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback - missing required data
|
|
||||||
return <ReportErrorState title={topic} error="Missing report ID" />;
|
return <ReportErrorState title={topic} error="Missing report ID" />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue