mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
Merge upstream/dev into feature/human-in-the-loop
This commit is contained in:
commit
66a6fb685e
47 changed files with 7257 additions and 4582 deletions
350
surfsense_web/app/(home)/announcements/page.tsx
Normal file
350
surfsense_web/app/(home)/announcements/page.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
CheckCheck,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Info,
|
||||
type Megaphone,
|
||||
Rocket,
|
||||
Wrench,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const categoryConfig: Record<
|
||||
AnnouncementCategory,
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Megaphone;
|
||||
color: string;
|
||||
badgeVariant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
feature: {
|
||||
label: "Feature",
|
||||
icon: Rocket,
|
||||
color: "text-emerald-500",
|
||||
badgeVariant: "default",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
icon: Zap,
|
||||
color: "text-blue-500",
|
||||
badgeVariant: "secondary",
|
||||
},
|
||||
maintenance: {
|
||||
label: "Maintenance",
|
||||
icon: Wrench,
|
||||
color: "text-amber-500",
|
||||
badgeVariant: "outline",
|
||||
},
|
||||
info: {
|
||||
label: "Info",
|
||||
icon: Info,
|
||||
color: "text-muted-foreground",
|
||||
badgeVariant: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Announcement card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AnnouncementCard({
|
||||
announcement,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
}: {
|
||||
announcement: AnnouncementWithState;
|
||||
onMarkRead: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
}) {
|
||||
const config = categoryConfig[announcement.category];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group relative transition-all duration-200 hover:shadow-md ${
|
||||
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted ${config.color}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base leading-tight">{announcement.title}</CardTitle>
|
||||
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{announcement.isImportant && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 gap-0.5">
|
||||
<Bell className="h-2.5 w-2.5" />
|
||||
Important
|
||||
</Badge>
|
||||
)}
|
||||
{!announcement.isRead && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
{formatRelativeDate(announcement.date)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!announcement.isRead && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onMarkRead(announcement.id)}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDismiss(announcement.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dismiss</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{announcement.description}</p>
|
||||
</CardContent>
|
||||
|
||||
{announcement.link && (
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<Button variant="outline" size="sm" asChild className="gap-1.5">
|
||||
<Link
|
||||
href={announcement.link.url}
|
||||
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
|
||||
onClick={() => onMarkRead(announcement.id)}
|
||||
>
|
||||
{announcement.link.label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
{hasFilters ? (
|
||||
<Filter className="h-7 w-7 text-muted-foreground" />
|
||||
) : (
|
||||
<BellOff className="h-7 w-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{hasFilters ? "No matching announcements" : "No announcements"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
|
||||
{hasFilters
|
||||
? "Try adjusting your filters to see more announcements."
|
||||
: "You're all caught up! New announcements will appear here."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AnnouncementsPage() {
|
||||
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
|
||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
||||
|
||||
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
|
||||
includeDismissed: false,
|
||||
});
|
||||
|
||||
// Apply local filters
|
||||
const filteredAnnouncements = announcements.filter((a) => {
|
||||
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
|
||||
if (showOnlyUnread && a.isRead) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
|
||||
|
||||
const toggleCategory = (cat: AnnouncementCategory) => {
|
||||
setActiveCategories((prev) =>
|
||||
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="min-h-screen relative pt-20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border/50">
|
||||
<div className="max-w-5xl mx-auto relative">
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||
Announcements
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Category filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filter
|
||||
{activeCategories.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||
{activeCategories.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuLabel>Categories</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
|
||||
const cfg = categoryConfig[cat];
|
||||
const CatIcon = cfg.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={cat}
|
||||
checked={activeCategories.includes(cat)}
|
||||
onCheckedChange={() => toggleCategory(cat)}
|
||||
>
|
||||
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
|
||||
{cfg.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showOnlyUnread}
|
||||
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
|
||||
>
|
||||
<Bell className="mr-2 h-3.5 w-3.5" />
|
||||
Unread only
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setActiveCategories([]);
|
||||
setShowOnlyUnread(false);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mark all read */}
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all as read
|
||||
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{/* Announcement list */}
|
||||
{filteredAnnouncements.length === 0 ? (
|
||||
<EmptyState hasFilters={hasActiveFilters} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredAnnouncements.map((announcement) => (
|
||||
<AnnouncementCard
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
onMarkRead={markRead}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,15 +28,18 @@ import {
|
|||
// extractWriteTodosFromContent,
|
||||
hydratePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
|
||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page";
|
||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
|
||||
|
|
@ -131,6 +134,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
|||
*/
|
||||
const TOOLS_WITH_UI = new Set([
|
||||
"generate_podcast",
|
||||
"generate_report",
|
||||
"link_preview",
|
||||
"display_image",
|
||||
"delete_notion_page",
|
||||
|
|
@ -170,6 +174,7 @@ export default function NewChatPage() {
|
|||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
||||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -263,6 +268,7 @@ export default function NewChatPage() {
|
|||
setMentionedDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
|
||||
closeReportPanel(); // Close report panel when switching chats
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
|
|
@ -327,6 +333,7 @@ export default function NewChatPage() {
|
|||
setMentionedDocumentIds,
|
||||
setMentionedDocuments,
|
||||
hydratePlanState,
|
||||
closeReportPanel,
|
||||
]);
|
||||
|
||||
// Initialize on mount
|
||||
|
|
@ -1635,6 +1642,7 @@ export default function NewChatPage() {
|
|||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<GeneratePodcastToolUI />
|
||||
<GenerateReportToolUI />
|
||||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
|
|
@ -1644,11 +1652,14 @@ export default function NewChatPage() {
|
|||
<UpdateNotionPageToolUI />
|
||||
<DeleteNotionPageToolUI />
|
||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||
<div className="flex flex-col h-[calc(100dvh-64px)] overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
/>
|
||||
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread
|
||||
messageThinkingSteps={messageThinkingSteps}
|
||||
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||
/>
|
||||
</div>
|
||||
<ReportPanel />
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -225,3 +225,5 @@ button {
|
|||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
@source '../node_modules/@streamdown/code/dist/*.js';
|
||||
@source '../node_modules/@streamdown/math/dist/*.js';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||
import "./globals.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
|
||||
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
|
|
@ -124,6 +125,7 @@ export default function RootLayout({
|
|||
</ElectricProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom";
|
||||
|
||||
// TODO: Update `hasComments` to true when the first comment is created on a thread.
|
||||
// Currently it only updates on thread load. The gutter still works because
|
||||
|
|
@ -39,6 +40,8 @@ export const showCommentsGutterAtom = atom((get) => {
|
|||
const thread = get(currentThreadAtom);
|
||||
// Hide gutter if comments are collapsed
|
||||
if (thread.commentsCollapsed) return false;
|
||||
// Hide gutter if report panel is open (report panel takes the right side)
|
||||
if (get(reportPanelOpenAtom)) return false;
|
||||
return (
|
||||
thread.visibility === "SEARCH_SPACE" &&
|
||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
||||
|
|
@ -59,6 +62,8 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat
|
|||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
// Also close the report panel when resetting the thread
|
||||
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
|
||||
});
|
||||
|
||||
/** Atom to read whether comments panel is collapsed */
|
||||
|
|
|
|||
52
surfsense_web/atoms/chat/report-panel.atom.ts
Normal file
52
surfsense_web/atoms/chat/report-panel.atom.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
interface ReportPanelState {
|
||||
isOpen: boolean;
|
||||
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 = {
|
||||
isOpen: false,
|
||||
reportId: null,
|
||||
title: null,
|
||||
wordCount: null,
|
||||
shareToken: null,
|
||||
};
|
||||
|
||||
/** Core atom holding the report panel state */
|
||||
export const reportPanelAtom = atom<ReportPanelState>(initialState);
|
||||
|
||||
/** Derived read-only atom for checking if panel is open */
|
||||
export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen);
|
||||
|
||||
/** Action atom to open the report panel with a specific report */
|
||||
export const openReportPanelAtom = atom(
|
||||
null,
|
||||
(
|
||||
_get,
|
||||
set,
|
||||
{
|
||||
reportId,
|
||||
title,
|
||||
wordCount,
|
||||
shareToken,
|
||||
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
|
||||
) => {
|
||||
set(reportPanelAtom, {
|
||||
isOpen: true,
|
||||
reportId,
|
||||
title,
|
||||
wordCount: wordCount ?? null,
|
||||
shareToken: shareToken ?? null,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/** Action atom to close the report panel */
|
||||
export const closeReportPanelAtom = atom(null, (_, set) => {
|
||||
set(reportPanelAtom, initialState);
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Announcement } from "@/contracts/types/announcement.types";
|
||||
import { announcements } from "@/lib/announcements/announcements-data";
|
||||
import {
|
||||
isAnnouncementToasted,
|
||||
markAnnouncementRead,
|
||||
markAnnouncementToasted,
|
||||
} from "@/lib/announcements/announcements-storage";
|
||||
|
||||
/** Map announcement category to the Sonner toast method */
|
||||
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
|
||||
update: "info",
|
||||
feature: "success",
|
||||
maintenance: "warning",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
/** Show a single announcement as a toast */
|
||||
function showAnnouncementToast(announcement: Announcement) {
|
||||
const variant = categoryToVariant[announcement.category] ?? "info";
|
||||
|
||||
const options = {
|
||||
description: truncateText(announcement.description, 120),
|
||||
duration: 12000,
|
||||
icon: <Megaphone className="h-4 w-4" />,
|
||||
action: announcement.link
|
||||
? {
|
||||
label: announcement.link.label,
|
||||
onClick: () => {
|
||||
if (announcement.link?.url.startsWith("http")) {
|
||||
window.open(announcement.link.url, "_blank");
|
||||
} else if (announcement.link?.url) {
|
||||
window.location.href = announcement.link.url;
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
onDismiss: () => {
|
||||
markAnnouncementRead(announcement.id);
|
||||
},
|
||||
};
|
||||
|
||||
toast[variant](announcement.title, options);
|
||||
markAnnouncementToasted(announcement.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global provider that shows important announcements as toast notifications.
|
||||
*
|
||||
* Place this component once at the root layout level (alongside <Toaster />).
|
||||
* On mount, it checks for unread important announcements that haven't been
|
||||
* shown as toasts yet, and displays them with a short stagger delay.
|
||||
*/
|
||||
export function AnnouncementToastProvider() {
|
||||
const hasChecked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once per page load
|
||||
if (hasChecked.current) return;
|
||||
hasChecked.current = true;
|
||||
|
||||
// Small delay to let the page settle before showing toasts
|
||||
const timer = setTimeout(() => {
|
||||
const importantUntoasted = announcements.filter(
|
||||
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
||||
);
|
||||
|
||||
// Show each important announcement as a toast with stagger
|
||||
for (let i = 0; i < importantUntoasted.length; i++) {
|
||||
const announcement = importantUntoasted[i];
|
||||
setTimeout(() => showAnnouncementToast(announcement), i * 800);
|
||||
}
|
||||
}, 1500); // Initial delay for page to settle
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// This component renders nothing — it only triggers side effects
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Truncate text to a maximum length, adding ellipsis if needed */
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`;
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ export function FooterNew() {
|
|||
title: "Contact Us",
|
||||
href: "/contact",
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
href: "/announcements",
|
||||
},
|
||||
];
|
||||
|
||||
const socials = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
IconBrandGithub,
|
||||
IconBrandReddit,
|
||||
IconMenu2,
|
||||
IconSpeakerphone,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
|
@ -12,6 +13,7 @@ import { useEffect, useState } from "react";
|
|||
import { SignInButton } from "@/components/auth/sign-in-button";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useGithubStars } from "@/hooks/use-github-stars";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -47,7 +49,11 @@ export const Navbar = () => {
|
|||
|
||||
const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
|
||||
const { unreadCount } = useAnnouncements();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => {
|
||||
|
|
@ -118,6 +124,17 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="relative hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
{mounted && unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
<SignInButton variant="desktop" />
|
||||
</div>
|
||||
|
|
@ -127,7 +144,11 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
|
||||
const MobileNav = ({ navItems, isScrolled }: any) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
|
||||
const { unreadCount } = useAnnouncements();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -212,6 +233,17 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
|||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
|
||||
>
|
||||
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
{mounted && unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
<SignInButton variant="mobile" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
PencilIcon,
|
||||
SquareLibrary,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -23,6 +31,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
|
|
@ -65,6 +74,9 @@ export function LayoutDataProvider({
|
|||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// Announcements
|
||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
|
|
@ -293,8 +305,15 @@ export function LayoutDataProvider({
|
|||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Announcements",
|
||||
url: "/announcements",
|
||||
icon: Megaphone,
|
||||
isActive: pathname?.includes("/announcements"),
|
||||
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export function SearchSpaceAvatar({
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
|
||||
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all select-none",
|
||||
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
sizeClasses,
|
||||
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function UserAvatar({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white select-none"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,67 @@
|
|||
import { createCodePlugin } from "@streamdown/code";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import Image from "next/image";
|
||||
import { Streamdown, type StreamdownProps } from "streamdown";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const code = createCodePlugin({
|
||||
themes: ["nord", "nord"],
|
||||
});
|
||||
|
||||
const math = createMathPlugin({
|
||||
singleDollarTextMath: true,
|
||||
});
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the entire content is wrapped in a single ```markdown or ```md
|
||||
* code fence, strip the fence so the inner markdown renders properly.
|
||||
*/
|
||||
function stripOuterMarkdownFence(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/);
|
||||
return match ? match[1] : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various LaTeX delimiter styles to the dollar-sign syntax
|
||||
* that remark-math understands, and normalise edge-cases that
|
||||
* commonly appear in LLM-generated markdown.
|
||||
*
|
||||
* \[...\] → $$ ... $$ (block / display math)
|
||||
* \(...\) → $ ... $ (inline math)
|
||||
* same-line $$…$$ → $ ... $ (inline math — display math
|
||||
* can't live inside table cells)
|
||||
* `$$ … $$` → $$ … $$ (strip wrapping backtick code)
|
||||
* `$ … $` → $ … $ (strip wrapping backtick code)
|
||||
*/
|
||||
function convertLatexDelimiters(content: string): string {
|
||||
// 1. Block math: \[...\] → $$...$$
|
||||
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => {
|
||||
return `$$${inner}$$`;
|
||||
});
|
||||
// 2. Inline math: \(...\) → $...$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => {
|
||||
return `$${inner}$`;
|
||||
});
|
||||
// 3. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
|
||||
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
|
||||
// 4. Same-line $$...$$ → $...$ (inline math) so it works inside table cells.
|
||||
// True display math has $$ on its own line, so this only affects inline usage.
|
||||
content = content.replace(/\$\$([^\n]+?)\$\$/g, (_match, inner) => {
|
||||
return `$${inner}$`;
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
||||
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
|
||||
const components: StreamdownProps["components"] = {
|
||||
// Define custom components for markdown elements
|
||||
callout: ({ children, ...props }) => (
|
||||
<div
|
||||
className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
p: ({ children, ...props }) => (
|
||||
<p className="my-2" {...props}>
|
||||
{children}
|
||||
|
|
@ -71,42 +115,41 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
|
|||
/>
|
||||
),
|
||||
table: ({ ...props }) => (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table className="min-w-full divide-y divide-border" {...props} />
|
||||
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
|
||||
<table className="w-full divide-y divide-border" {...props} />
|
||||
</div>
|
||||
),
|
||||
th: ({ ...props }) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />,
|
||||
td: ({ ...props }) => <td className="px-3 py-2 border-t border-border" {...props} />,
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isInline = !match;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// For code blocks, let Streamdown handle syntax highlighting
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
th: ({ ...props }) => (
|
||||
<th
|
||||
className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ ...props }) => (
|
||||
<td
|
||||
className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_pre]:overflow-x-auto [&_code]:wrap-break-word [&_table]:block [&_table]:overflow-x-auto",
|
||||
"max-w-none overflow-hidden",
|
||||
"[&_[data-streamdown=code-block-header]]:!bg-transparent",
|
||||
"[&_[data-streamdown=code-block]>*]:!border-none [&_[data-streamdown=code-block]>*]:![box-shadow:none]",
|
||||
"[&_[data-streamdown=code-block-download-button]]:!hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Streamdown components={components} shikiTheme={["github-light", "github-dark"]}>
|
||||
{content}
|
||||
<Streamdown
|
||||
components={components}
|
||||
plugins={{ code, math }}
|
||||
controls={{ code: true }}
|
||||
mode="static"
|
||||
>
|
||||
{processedContent}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -138,6 +138,26 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||
{hasPublicSnapshots && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{snapshotCount === 1
|
||||
? "This chat has a public link"
|
||||
: `This chat has ${snapshotCount} public links`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -242,26 +262,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Globe indicator when public snapshots exist - clicks to settings */}
|
||||
{hasPublicSnapshots && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{snapshotCount === 1
|
||||
? "This chat has a public link"
|
||||
: `This chat has ${snapshotCount} public links`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||
import { Navbar } from "@/components/homepage/navbar";
|
||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { GenerateReportToolUI } from "@/components/tool-ui/generate-report";
|
||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
|
@ -42,12 +44,16 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
|
|||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
{/* Tool UIs for rendering tool results */}
|
||||
<GeneratePodcastToolUI />
|
||||
<GenerateReportToolUI />
|
||||
<LinkPreviewToolUI />
|
||||
<DisplayImageToolUI />
|
||||
<ScrapeWebpageToolUI />
|
||||
|
||||
<div className="flex h-screen flex-col pt-16">
|
||||
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
|
||||
<div className="flex h-screen pt-16 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
|
||||
</div>
|
||||
<ReportPanel />
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
</main>
|
||||
|
|
|
|||
490
surfsense_web/components/report-panel/report-panel.tsx
Normal file
490
surfsense_web/components/report-panel/report-panel.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
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 { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
* Zod schema for a single version entry
|
||||
*/
|
||||
const VersionInfoSchema = z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string().nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the report content API response
|
||||
*/
|
||||
const ReportContentResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
content: z.string().nullish(),
|
||||
report_metadata: z
|
||||
.object({
|
||||
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(),
|
||||
})
|
||||
.nullish(),
|
||||
report_group_id: z.number().nullish(),
|
||||
versions: z.array(VersionInfoSchema).nullish(),
|
||||
});
|
||||
|
||||
type ReportContentResponse = z.infer<typeof ReportContentResponseSchema>;
|
||||
type VersionInfo = z.infer<typeof VersionInfoSchema>;
|
||||
|
||||
/**
|
||||
* Shimmer loading skeleton for report panel
|
||||
*/
|
||||
function ReportPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Title skeleton */}
|
||||
<div className="h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" />
|
||||
|
||||
{/* Paragraph 1 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse" />
|
||||
<div className="h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
|
||||
<div className="h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
|
||||
<div className="h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
|
||||
|
||||
{/* Paragraph 2 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
|
||||
<div className="h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
|
||||
<div className="h-3 w-[97%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="h-5 w-1/3 rounded-md bg-muted/60 animate-pulse [animation-delay:800ms]" />
|
||||
|
||||
{/* Paragraph 3 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="h-3 w-[90%] rounded-md bg-muted/60 animate-pulse [animation-delay:900ms]" />
|
||||
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:1000ms]" />
|
||||
<div className="h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:1100ms]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner content component used by both desktop panel and mobile drawer
|
||||
*/
|
||||
function ReportPanelContent({
|
||||
reportId,
|
||||
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<ReportContentResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null);
|
||||
|
||||
// Version state
|
||||
const [activeReportId, setActiveReportId] = useState(reportId);
|
||||
const [versions, setVersions] = useState<VersionInfo[]>([]);
|
||||
|
||||
// Reset active version when the external reportId changes (e.g. clicking a different card)
|
||||
useEffect(() => {
|
||||
setActiveReportId(reportId);
|
||||
}, [reportId]);
|
||||
|
||||
// Fetch report content (re-runs when activeReportId changes for version switching)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchContent = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = shareToken
|
||||
? `/api/v1/public/${shareToken}/reports/${activeReportId}/content`
|
||||
: `/api/v1/reports/${activeReportId}/content`;
|
||||
const rawData = await baseApiService.get<unknown>(url);
|
||||
if (cancelled) return;
|
||||
const parsed = ReportContentResponseSchema.safeParse(rawData);
|
||||
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");
|
||||
} else {
|
||||
setReportContent(parsed.data);
|
||||
// Update versions from the response
|
||||
if (parsed.data.versions && parsed.data.versions.length > 0) {
|
||||
setVersions(parsed.data.versions);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeReportId, shareToken]);
|
||||
|
||||
// Copy markdown content
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!reportContent?.content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reportContent.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}, [reportContent?.content]);
|
||||
|
||||
// Export report
|
||||
const handleExport = useCallback(
|
||||
async (format: "pdf" | "docx" | "md") => {
|
||||
setExporting(format);
|
||||
const safeTitle =
|
||||
title
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 80) || "report";
|
||||
try {
|
||||
if (format === "md") {
|
||||
// Download markdown content directly as a .md file
|
||||
if (!reportContent?.content) return;
|
||||
const blob = new Blob([reportContent.content], {
|
||||
type: "text/markdown;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Export ${format} failed:`, err);
|
||||
} finally {
|
||||
setExporting(null);
|
||||
}
|
||||
},
|
||||
[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;
|
||||
|
||||
if (isLoading && !hasLoadedBefore) {
|
||||
return (
|
||||
<>
|
||||
{/* Minimal top bar with close button even during initial load */}
|
||||
<div className="flex items-center justify-end px-4 py-2 shrink-0">
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close report panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ReportPanelSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action bar — always visible after initial load */}
|
||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
|
||||
{/* Export dropdown */}
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
||||
>
|
||||
Export
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||
Download Markdown
|
||||
</DropdownMenuItem>
|
||||
{/* PDF/DOCX export requires server-side conversion via authenticated endpoint.
|
||||
Hide for public viewers who have no auth token. */}
|
||||
{!shareToken && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting === "pdf" && <Spinner size="xs" />}
|
||||
Download PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport("docx")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting === "docx" && <Spinner size="xs" />}
|
||||
Download DOCX
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Version switcher — only shown when multiple versions exist */}
|
||||
{versions.length > 1 &&
|
||||
(insideDrawer ? (
|
||||
/* Mobile: compact dropdown */
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
||||
>
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[120px] z-[100]">
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
|
||||
>
|
||||
Version {i + 1}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
/* Desktop: inline version buttons */
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||
{versions.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
|
||||
v.id === activeReportId
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
v{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
|
||||
{activeVersionIndex + 1} of {versions.length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close report panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report content — skeleton/error/content shown only in this area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<ReportPanelSkeleton />
|
||||
) : error || !reportContent ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Failed to load report</p>
|
||||
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-5">
|
||||
{reportContent.content ? (
|
||||
<MarkdownViewer content={reportContent.content} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">No content available.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop report panel — renders as a right-side flex sibling
|
||||
*/
|
||||
function DesktopReportPanel() {
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
const closePanel = useSetAtom(closeReportPanelAtom);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close panel on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closePanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePanel]);
|
||||
|
||||
if (!panelState.isOpen || !panelState.reportId) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-background animate-in slide-in-from-right-4 duration-300 ease-out"
|
||||
>
|
||||
<ReportPanelContent
|
||||
reportId={panelState.reportId}
|
||||
title={panelState.title || "Report"}
|
||||
onClose={closePanel}
|
||||
shareToken={panelState.shareToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile report drawer — uses Vaul (same pattern as comment sheet)
|
||||
*/
|
||||
function MobileReportDrawer() {
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
const closePanel = useSetAtom(closeReportPanelAtom);
|
||||
|
||||
if (!panelState.reportId) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={panelState.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closePanel();
|
||||
}}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="h-[90vh] max-h-[90vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<ReportPanelContent
|
||||
reportId={panelState.reportId}
|
||||
title={panelState.title || "Report"}
|
||||
insideDrawer
|
||||
shareToken={panelState.shareToken}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportPanel — responsive report viewer
|
||||
*
|
||||
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
|
||||
* On mobile/tablet: Renders as a Vaul bottom drawer
|
||||
*
|
||||
* When open on desktop, the comments gutter is automatically suppressed
|
||||
* (handled via showCommentsGutterAtom in current-thread.atom.ts)
|
||||
*/
|
||||
export function ReportPanel() {
|
||||
const panelState = useAtomValue(reportPanelAtom);
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
// Don't render anything if panel is not open
|
||||
if (!panelState.isOpen || !panelState.reportId) return null;
|
||||
|
||||
if (isDesktop) {
|
||||
return <DesktopReportPanel />;
|
||||
}
|
||||
|
||||
return <MobileReportDrawer />;
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import {
|
|||
FileTextIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Component, type ReactNode, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { Component, type ReactNode, useCallback, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -126,6 +127,30 @@ function formatWordCount(count: number): string {
|
|||
return `${count} words`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Favicon component that fetches the site icon via Google's favicon service,
|
||||
* falling back to BookOpenIcon on error.
|
||||
*/
|
||||
function SiteFavicon({ domain }: { domain: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return <BookOpenIcon className="size-5 text-primary" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`}
|
||||
alt={`${domain} favicon`}
|
||||
width={28}
|
||||
height={28}
|
||||
className="size-5 sm:size-7 rounded-sm"
|
||||
onError={() => setFailed(true)}
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Article card component for displaying scraped webpage content
|
||||
*/
|
||||
|
|
@ -198,27 +223,35 @@ export function Article({
|
|||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<BookOpenIcon className="size-5 text-primary" />
|
||||
</div>
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
{/* Favicon / Icon */}
|
||||
{domain ? (
|
||||
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center">
|
||||
<SiteFavicon domain={domain} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<BookOpenIcon className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors">
|
||||
<h3 className="font-semibold text-xs sm:text-sm line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">{description}</p>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs mt-1 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
{domain && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -274,13 +307,6 @@ export function Article({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External link indicator */}
|
||||
{href && (
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response actions */}
|
||||
|
|
|
|||
303
surfsense_web/components/tool-ui/generate-report.tsx
Normal file
303
surfsense_web/components/tool-ui/generate-report.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Dot, FileTextIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
|
||||
/**
|
||||
* Zod schemas for runtime validation
|
||||
*/
|
||||
const GenerateReportArgsSchema = z.object({
|
||||
topic: z.string(),
|
||||
source_content: z.string(),
|
||||
report_style: z.string().nullish(),
|
||||
user_instructions: z.string().nullish(),
|
||||
parent_report_id: z.number().nullish(),
|
||||
});
|
||||
|
||||
const GenerateReportResultSchema = z.object({
|
||||
status: z.enum(["ready", "failed"]),
|
||||
report_id: z.number().nullish(),
|
||||
title: z.string().nullish(),
|
||||
word_count: z.number().nullish(),
|
||||
message: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
const ReportMetadataResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
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(),
|
||||
})
|
||||
.nullish(),
|
||||
report_group_id: z.number().nullish(),
|
||||
versions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string().nullish(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Types derived from Zod schemas
|
||||
*/
|
||||
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
|
||||
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
|
||||
|
||||
/**
|
||||
* Loading state component shown while report is being generated.
|
||||
* Matches the compact card layout of the completed ReportCard.
|
||||
*/
|
||||
function ReportGeneratingState({ topic }: { topic: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
||||
<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="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="size-4 sm:size-6 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
|
||||
{topic}
|
||||
</h3>
|
||||
<TextShimmerLoader text="Putting things together" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error state component shown when report generation fails
|
||||
*/
|
||||
function ReportErrorState({ title, error }: { title: string; error: string }) {
|
||||
return (
|
||||
<div className="my-4 overflow-hidden rounded-xl border bg-card">
|
||||
<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="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-muted/60">
|
||||
<FileTextIcon className="size-4 sm:size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight line-clamp-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground/60 text-[11px] sm:text-xs mt-0.5 truncate">{error}</p>
|
||||
</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({
|
||||
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);
|
||||
const [metadata, setMetadata] = useState<{
|
||||
title: string;
|
||||
wordCount: number | null;
|
||||
versionLabel: string | null;
|
||||
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch lightweight metadata (title + counts + version info)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchMetadata = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = shareToken
|
||||
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
|
||||
: `/api/v1/reports/${reportId}/content`;
|
||||
const rawData = await baseApiService.get<unknown>(url);
|
||||
if (cancelled) return;
|
||||
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
|
||||
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");
|
||||
} else {
|
||||
// Determine version label from versions array
|
||||
let versionLabel: string | null = null;
|
||||
const versions = parsed.data.versions;
|
||||
if (versions && versions.length > 1) {
|
||||
const idx = versions.findIndex((v) => v.id === reportId);
|
||||
if (idx >= 0) {
|
||||
versionLabel = `version ${idx + 1}`;
|
||||
}
|
||||
}
|
||||
setMetadata({
|
||||
title: parsed.data.title || title,
|
||||
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null,
|
||||
versionLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("No report found");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMetadata();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reportId, title, wordCount, shareToken]);
|
||||
|
||||
// Show non-clickable error card for any error (failed status, not found, etc.)
|
||||
if (!isLoading && error) {
|
||||
return <ReportErrorState title={title} error={error} />;
|
||||
}
|
||||
|
||||
const isActive = panelState.isOpen && panelState.reportId === reportId;
|
||||
|
||||
const handleOpen = () => {
|
||||
openPanel({
|
||||
reportId,
|
||||
title: metadata.title,
|
||||
wordCount: metadata.wordCount ?? undefined,
|
||||
shareToken,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-1 ring-primary/50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="size-4 sm:size-6 text-primary" />
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
|
||||
{isLoading ? (
|
||||
<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.versionLabel && (
|
||||
<Dot className="inline size-4" />
|
||||
)}
|
||||
{metadata.versionLabel}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Report Tool UI Component
|
||||
*
|
||||
* This component is registered with assistant-ui to render custom UI
|
||||
* 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>({
|
||||
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)
|
||||
if (status.type === "running" || status.type === "requires-action") {
|
||||
return <ReportGeneratingState topic={topic} />;
|
||||
}
|
||||
|
||||
// Incomplete/cancelled state
|
||||
if (status.type === "incomplete") {
|
||||
if (status.reason === "cancelled") {
|
||||
return (
|
||||
<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") {
|
||||
return (
|
||||
<ReportErrorState
|
||||
title={topic}
|
||||
error={typeof status.error === "string" ? status.error : "An error occurred"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return <ReportGeneratingState topic={topic} />;
|
||||
}
|
||||
|
||||
// Failed result
|
||||
if (result.status === "failed") {
|
||||
return (
|
||||
<ReportErrorState
|
||||
title={result.title || topic}
|
||||
error={result.error || "Generation failed"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready with report_id
|
||||
if (result.status === "ready" && result.report_id) {
|
||||
return (
|
||||
<ReportCard
|
||||
reportId={result.report_id}
|
||||
title={result.title || topic}
|
||||
wordCount={result.word_count ?? undefined}
|
||||
shareToken={shareToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback - missing required data
|
||||
return <ReportErrorState title={topic} error="Missing report ID" />;
|
||||
},
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ export {
|
|||
DisplayImageToolUI,
|
||||
} from "./display-image";
|
||||
export { GeneratePodcastToolUI } from "./generate-podcast";
|
||||
export { GenerateReportToolUI } from "./generate-report";
|
||||
export {
|
||||
Image,
|
||||
ImageErrorBoundary,
|
||||
|
|
|
|||
|
|
@ -87,20 +87,9 @@ function ScrapeCancelledState({ url }: { url: string }) {
|
|||
* Parsed Article component with error handling
|
||||
*/
|
||||
function ParsedArticle({ result }: { result: unknown }) {
|
||||
const article = parseSerializableArticle(result);
|
||||
const { description, ...article } = parseSerializableArticle(result);
|
||||
|
||||
return (
|
||||
<Article
|
||||
{...article}
|
||||
maxWidth="480px"
|
||||
responseActions={[{ id: "open", label: "Open Source", variant: "default" }]}
|
||||
onResponseAction={(id) => {
|
||||
if (id === "open" && article.href) {
|
||||
window.open(article.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <Article {...article} maxWidth="480px" />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
42
surfsense_web/contracts/types/announcement.types.ts
Normal file
42
surfsense_web/contracts/types/announcement.types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Announcement system types
|
||||
*
|
||||
* Frontend-only announcement system that supports:
|
||||
* - Multiple announcement categories (update, feature, maintenance, info)
|
||||
* - Important flag for toast notifications
|
||||
* - Read/dismissed state tracking via localStorage
|
||||
*/
|
||||
|
||||
/** Announcement category */
|
||||
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
|
||||
|
||||
/** Single announcement entry */
|
||||
export interface Announcement {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Short title */
|
||||
title: string;
|
||||
/** Full description (supports basic text) */
|
||||
description: string;
|
||||
/** Category for visual styling and filtering */
|
||||
category: AnnouncementCategory;
|
||||
/** ISO date string of when the announcement was published */
|
||||
date: string;
|
||||
/** If true, the user will see a toast notification for this announcement */
|
||||
isImportant: boolean;
|
||||
/** Optional CTA link */
|
||||
link?: {
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** State stored in localStorage for tracking user interactions */
|
||||
export interface AnnouncementUserState {
|
||||
/** IDs of announcements the user has read (clicked/viewed) */
|
||||
readIds: string[];
|
||||
/** IDs of important announcements already shown as toasts */
|
||||
toastedIds: string[];
|
||||
/** IDs of announcements the user has explicitly dismissed */
|
||||
dismissedIds: string[];
|
||||
}
|
||||
119
surfsense_web/hooks/use-announcements.ts
Normal file
119
surfsense_web/hooks/use-announcements.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
|
||||
import { announcements } from "@/lib/announcements/announcements-data";
|
||||
import {
|
||||
dismissAnnouncement,
|
||||
getAnnouncementState,
|
||||
isAnnouncementDismissed,
|
||||
isAnnouncementRead,
|
||||
markAllAnnouncementsRead,
|
||||
markAnnouncementRead,
|
||||
} from "@/lib/announcements/announcements-storage";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External-store plumbing so React re-renders when localStorage changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let stateVersion = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return stateVersion;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Bump the version so useSyncExternalStore triggers a re-render */
|
||||
function notify() {
|
||||
stateVersion++;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enriched announcement with read/dismissed state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AnnouncementWithState extends Announcement {
|
||||
isRead: boolean;
|
||||
isDismissed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UseAnnouncementsOptions {
|
||||
/** Filter by category */
|
||||
category?: AnnouncementCategory;
|
||||
/** If true, include dismissed announcements (default: false) */
|
||||
includeDismissed?: boolean;
|
||||
}
|
||||
|
||||
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
|
||||
const { category, includeDismissed = false } = options;
|
||||
|
||||
// Subscribe to state changes (re-renders when localStorage state is bumped)
|
||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const enriched: AnnouncementWithState[] = useMemo(() => {
|
||||
let items = announcements.map((a) => ({
|
||||
...a,
|
||||
isRead: isAnnouncementRead(a.id),
|
||||
isDismissed: isAnnouncementDismissed(a.id),
|
||||
}));
|
||||
|
||||
if (category) {
|
||||
items = items.filter((a) => a.category === category);
|
||||
}
|
||||
|
||||
if (!includeDismissed) {
|
||||
items = items.filter((a) => !a.isDismissed);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return items;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [category, includeDismissed, stateVersion]);
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
|
||||
[enriched]
|
||||
);
|
||||
|
||||
const handleMarkRead = useCallback((id: string) => {
|
||||
markAnnouncementRead(id);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
const state = getAnnouncementState();
|
||||
const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
|
||||
markAllAnnouncementsRead(unreadIds);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
dismissAnnouncement(id);
|
||||
markAnnouncementRead(id);
|
||||
notify();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
announcements: enriched,
|
||||
unreadCount,
|
||||
markRead: handleMarkRead,
|
||||
markAllRead: handleMarkAllRead,
|
||||
dismiss: handleDismiss,
|
||||
};
|
||||
}
|
||||
65
surfsense_web/lib/announcements/announcements-data.ts
Normal file
65
surfsense_web/lib/announcements/announcements-data.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Announcement } from "@/contracts/types/announcement.types";
|
||||
|
||||
/**
|
||||
* Static announcements data.
|
||||
*
|
||||
* To add a new announcement, append an entry to this array.
|
||||
* Set `isImportant: true` to trigger a toast notification for the user.
|
||||
*
|
||||
* This file can be replaced with an API call in the future.
|
||||
*/
|
||||
export const announcements: Announcement[] = [
|
||||
{
|
||||
id: "2026-02-12-announcement-syste",
|
||||
title: "Introducing Announcements",
|
||||
description:
|
||||
"Stay up to date with the latest SurfSense news! Important announcements will appear as toast notifications so you never miss critical updates. Visit the Announcements page from the sidebar to browse all past announcements.",
|
||||
category: "feature",
|
||||
date: "2026-02-12T00:00:00Z",
|
||||
isImportant: true,
|
||||
link: {
|
||||
label: "Learn more",
|
||||
url: "/changelog",
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: "2026-02-10-podcast-improvements",
|
||||
// title: "Podcast Generation Improvements",
|
||||
// description:
|
||||
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
|
||||
// category: "update",
|
||||
// date: "2026-02-10T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// },
|
||||
// {
|
||||
// id: "2026-02-08-scheduled-maintenance",
|
||||
// title: "Scheduled Maintenance — Feb 15",
|
||||
// description:
|
||||
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
|
||||
// category: "maintenance",
|
||||
// date: "2026-02-08T00:00:00Z",
|
||||
// isImportant: true,
|
||||
// },
|
||||
// {
|
||||
// id: "2026-02-05-new-connectors",
|
||||
// title: "New Connectors Available",
|
||||
// description:
|
||||
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
|
||||
// category: "feature",
|
||||
// date: "2026-02-05T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// link: {
|
||||
// label: "View connectors",
|
||||
// url: "#connectors",
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// id: "2026-01-28-team-collaboration",
|
||||
// title: "Enhanced Team Collaboration",
|
||||
// description:
|
||||
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
|
||||
// category: "feature",
|
||||
// date: "2026-01-28T00:00:00Z",
|
||||
// isImportant: false,
|
||||
// },
|
||||
];
|
||||
107
surfsense_web/lib/announcements/announcements-storage.ts
Normal file
107
surfsense_web/lib/announcements/announcements-storage.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { AnnouncementUserState } from "@/contracts/types/announcement.types";
|
||||
|
||||
const STORAGE_KEY = "surfsense_announcements_state";
|
||||
|
||||
const defaultState: AnnouncementUserState = {
|
||||
readIds: [],
|
||||
toastedIds: [],
|
||||
dismissedIds: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current announcement user state from localStorage
|
||||
*/
|
||||
export function getAnnouncementState(): AnnouncementUserState {
|
||||
if (typeof window === "undefined") return defaultState;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultState;
|
||||
const parsed = JSON.parse(raw) as Partial<AnnouncementUserState>;
|
||||
return {
|
||||
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
|
||||
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
|
||||
dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [],
|
||||
};
|
||||
} catch {
|
||||
return defaultState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save announcement user state to localStorage
|
||||
*/
|
||||
function saveAnnouncementState(state: AnnouncementUserState): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Silently fail if localStorage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an announcement as read
|
||||
*/
|
||||
export function markAnnouncementRead(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.readIds.includes(id)) {
|
||||
state.readIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all announcements as read
|
||||
*/
|
||||
export function markAllAnnouncementsRead(ids: string[]): void {
|
||||
const state = getAnnouncementState();
|
||||
const newIds = ids.filter((id) => !state.readIds.includes(id));
|
||||
if (newIds.length > 0) {
|
||||
state.readIds.push(...newIds);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss an announcement (hide it from the list)
|
||||
*/
|
||||
export function dismissAnnouncement(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.dismissedIds.includes(id)) {
|
||||
state.dismissedIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an important announcement as already toasted (shown as toast)
|
||||
*/
|
||||
export function markAnnouncementToasted(id: string): void {
|
||||
const state = getAnnouncementState();
|
||||
if (!state.toastedIds.includes(id)) {
|
||||
state.toastedIds.push(id);
|
||||
saveAnnouncementState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been read
|
||||
*/
|
||||
export function isAnnouncementRead(id: string): boolean {
|
||||
return getAnnouncementState().readIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been toasted
|
||||
*/
|
||||
export function isAnnouncementToasted(id: string): boolean {
|
||||
return getAnnouncementState().toastedIds.includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an announcement has been dismissed
|
||||
*/
|
||||
export function isAnnouncementDismissed(id: string): boolean {
|
||||
return getAnnouncementState().dismissedIds.includes(id);
|
||||
}
|
||||
|
|
@ -57,6 +57,8 @@
|
|||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@tanstack/query-core": "^5.90.7",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
|
|
@ -79,6 +81,7 @@
|
|||
"geist": "^1.4.2",
|
||||
"jotai": "^2.15.1",
|
||||
"jotai-tanstack-query": "^0.11.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.1.0",
|
||||
|
|
@ -101,7 +104,7 @@
|
|||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^2.0.6",
|
||||
"streamdown": "^1.6.10",
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
|
|
|
|||
1186
surfsense_web/pnpm-lock.yaml
generated
1186
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue