diff --git a/surfsense_backend/app/podcasts/api/schemas.py b/surfsense_backend/app/podcasts/api/schemas.py index cb8559651..e9d6e6b0c 100644 --- a/surfsense_backend/app/podcasts/api/schemas.py +++ b/surfsense_backend/app/podcasts/api/schemas.py @@ -84,6 +84,7 @@ class PodcastSummary(BaseModel): status: PodcastStatus created_at: datetime search_space_id: int + thread_id: int | None = None class PodcastDetail(BaseModel): diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 25ca50607..cfd9d89ca 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -24,6 +24,7 @@ class ReportRead(BaseModel): report_metadata: dict[str, Any] | None = None report_group_id: int | None = None content_type: str = "markdown" + thread_id: int | None = None created_at: datetime class Config: diff --git a/surfsense_backend/app/schemas/video_presentations.py b/surfsense_backend/app/schemas/video_presentations.py index ec29147ef..68ef3f5ba 100644 --- a/surfsense_backend/app/schemas/video_presentations.py +++ b/surfsense_backend/app/schemas/video_presentations.py @@ -44,6 +44,7 @@ class VideoPresentationRead(VideoPresentationBase): status: VideoPresentationStatusEnum = VideoPresentationStatusEnum.READY created_at: datetime slide_count: int | None = None + thread_id: int | None = None class Config: from_attributes = True @@ -68,6 +69,7 @@ class VideoPresentationRead(VideoPresentationBase): "status": obj.status, "created_at": obj.created_at, "slide_count": len(obj.slides) if obj.slides else None, + "thread_id": obj.thread_id, } return cls(**data) diff --git a/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx new file mode 100644 index 000000000..8f8109156 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/artifacts/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { ArtifactsLibrary } from "@/features/artifacts-library"; + +export default function ArtifactsPage() { + const params = useParams(); + const searchSpaceId = Number(params.search_space_id); + + return ; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 70d276264..5d420c1f1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -52,6 +52,7 @@ import { } from "@/components/assistant-ui/token-usage-context"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSyncChatArtifacts } from "@/features/chat-artifacts"; import { type HitlDecision, PendingInterruptProvider, @@ -138,6 +139,13 @@ const MobileReportPanel = dynamic( })), { ssr: false } ); +const MobileArtifactsPanel = dynamic( + () => + import("@/features/chat-artifacts/ui/artifacts-panel").then((m) => ({ + default: m.MobileArtifactsPanel, + })), + { ssr: false } +); /** * Generate a synthetic ``toolCallId`` for an action_request that has no @@ -2488,6 +2496,9 @@ export default function NewChatPage() { await handleRegenerate(null); }, [handleRegenerate]); + // Surface the thread's deliverables to the layout-level artifacts sidebar. + useSyncChatArtifacts(messages); + // Create external store runtime const runtime = useExternalStoreRuntime({ messages, @@ -2547,6 +2558,7 @@ export default function NewChatPage() { + ("sources"); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 59006b26e..616d3a797 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -58,6 +58,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { DropdownMenuLabel } from "@/components/ui/dropdown-menu"; +import { withArtifactAnchor } from "@/features/chat-artifacts"; import { useComments } from "@/hooks/use-comments"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; @@ -433,12 +434,12 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch * body and is picked up by the timeline instead. */ const BODY_TOOLS = { - generate_report: GenerateReportToolUI, - generate_resume: GenerateResumeToolUI, - generate_podcast: GeneratePodcastToolUI, - generate_video_presentation: GenerateVideoPresentationToolUI, - display_image: GenerateImageToolUI, - generate_image: GenerateImageToolUI, + generate_report: withArtifactAnchor(GenerateReportToolUI), + generate_resume: withArtifactAnchor(GenerateResumeToolUI), + generate_podcast: withArtifactAnchor(GeneratePodcastToolUI), + generate_video_presentation: withArtifactAnchor(GenerateVideoPresentationToolUI), + display_image: withArtifactAnchor(GenerateImageToolUI), + generate_image: withArtifactAnchor(GenerateImageToolUI), } as const; const NullBodyTool: ToolCallMessagePartComponent = () => null; diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 429a1fde8..433d66353 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { AlarmClock, AlertTriangle, Inbox, LibraryBig } from "lucide-react"; +import { AlarmClock, AlertTriangle, Boxes, Inbox, LibraryBig } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -328,6 +328,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // in the sidebar (also surfaced in the icon rail's collapsed mode via this // list). Announcements has been moved to the avatar dropdown. const isAutomationsActive = pathname?.includes("/automations") === true; + const isArtifactsActive = pathname?.endsWith("/artifacts") === true; const navItems: NavItem[] = useMemo( () => ( @@ -345,6 +346,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid icon: AlarmClock, isActive: isAutomationsActive, }, + { + title: "Artifacts", + url: `/dashboard/${searchSpaceId}/artifacts`, + icon: Boxes, + isActive: isArtifactsActive, + }, isMobile ? { title: "Documents", @@ -362,6 +369,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid totalUnreadCount, searchSpaceId, isAutomationsActive, + isArtifactsActive, ] ); diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx index ea700391a..af997ad5c 100644 --- a/surfsense_web/components/layout/ui/header/Header.tsx +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -7,6 +7,7 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { activeTabAtom } from "@/atoms/tabs/tabs.atom"; import { ActionLogButton } from "@/components/agent-action-log/action-log-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button"; +import { ArtifactsToggleButton } from "@/features/chat-artifacts"; import type { ThreadRecord } from "@/lib/chat/thread-persistence"; interface HeaderProps { @@ -71,6 +72,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) { {/* Right side - Actions */}
{hasThread && } + {hasThread && } {threadForButton && }
diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index 5a7588979..8d9f0454f 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -8,9 +8,14 @@ import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; -import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +import { + type RightPanelTab, + rightPanelCollapsedAtom, + rightPanelTabAtom, +} from "@/atoms/layout/right-panel.atom"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts"; import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl"; import { cn } from "@/lib/utils"; import { DocumentsSidebar } from "../sidebar"; @@ -47,6 +52,14 @@ const ReportPanelContent = dynamic( { ssr: false, loading: () => null } ); +const ArtifactsPanelContent = dynamic( + () => + import("@/features/chat-artifacts").then((m) => ({ + default: m.ArtifactsPanelContent, + })), + { ssr: false, loading: () => null } +); + interface RightPanelProps { documentsPanel?: { open: boolean; @@ -100,6 +113,7 @@ export function RightPanelToggleButton({ const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && @@ -110,7 +124,8 @@ export function RightPanelToggleButton({ : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; + const hasContent = + documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; const label = collapsed ? "Expand panel" : "Collapse panel"; if (!hasContent) return null; @@ -152,6 +167,7 @@ export function RightPanelExpandButton() { const editorState = useAtomValue(editorPanelAtom); const hitlEditState = useAtomValue(hitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && @@ -162,7 +178,8 @@ export function RightPanelExpandButton() { : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const citationOpen = citationState.isOpen && citationState.chunkId != null; - const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; + const hasContent = + documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen; if (!collapsed || !hasContent) return null; @@ -179,8 +196,31 @@ const PANEL_WIDTHS = { editor: 640, "hitl-edit": 640, citation: 560, + artifacts: 420, } as const; +/** + * Priority order used to fall back to another open surface when the active + * tab's content closes. Artifacts sit just above the always-available sources + * tab. + */ +const TAB_FALLBACK_ORDER: RightPanelTab[] = [ + "hitl-edit", + "citation", + "editor", + "report", + "artifacts", + "sources", +]; + +function resolveEffectiveTab( + activeTab: RightPanelTab, + openByTab: Record +): RightPanelTab { + if (openByTab[activeTab]) return activeTab; + return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources"; +} + export function RightPanel({ documentsPanel, showCollapseButton = true, @@ -195,6 +235,8 @@ export function RightPanel({ const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom); const citationState = useAtomValue(citationPanelAtom); const closeCitation = useSetAtom(closeCitationPanelAtom); + const artifactsOpen = useAtomValue(artifactsPanelOpenAtom); + const closeArtifacts = useSetAtom(closeArtifactsPanelAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const documentsOpen = documentsPanel?.open ?? false; @@ -210,13 +252,14 @@ export function RightPanel({ const citationOpen = citationState.isOpen && citationState.chunkId != null; useEffect(() => { - if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return; + if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (hitlEditOpen) closeHitlEdit(); else if (citationOpen) closeCitation(); else if (editorOpen) closeEditor(); else if (reportOpen) closeReport(); + else if (artifactsOpen) closeArtifacts(); } }; document.addEventListener("keydown", handleKeyDown); @@ -226,41 +269,26 @@ export function RightPanel({ editorOpen, hitlEditOpen, citationOpen, + artifactsOpen, closeReport, closeEditor, closeHitlEdit, closeCitation, + closeArtifacts, ]); const isVisible = - (documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed; + (documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) && + !collapsed; - let effectiveTab = activeTab; - if (effectiveTab === "hitl-edit" && !hitlEditOpen) { - effectiveTab = citationOpen - ? "citation" - : editorOpen - ? "editor" - : reportOpen - ? "report" - : "sources"; - } else if (effectiveTab === "citation" && !citationOpen) { - effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources"; - } else if (effectiveTab === "editor" && !editorOpen) { - effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources"; - } else if (effectiveTab === "report" && !reportOpen) { - effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources"; - } else if (effectiveTab === "sources" && !documentsOpen) { - effectiveTab = hitlEditOpen - ? "hitl-edit" - : citationOpen - ? "citation" - : editorOpen - ? "editor" - : reportOpen - ? "report" - : "sources"; - } + const effectiveTab = resolveEffectiveTab(activeTab, { + sources: documentsOpen, + report: reportOpen, + editor: editorOpen, + "hitl-edit": hitlEditOpen, + citation: citationOpen, + artifacts: artifactsOpen, + }); const targetWidth = PANEL_WIDTHS[effectiveTab]; const collapseButton = showCollapseButton ? ( @@ -329,6 +357,11 @@ export function RightPanel({ )} + {effectiveTab === "artifacts" && artifactsOpen && ( +
+ +
+ )} ); diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index ee891d78b..c274e1f97 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -145,6 +145,10 @@ export function Sidebar({ () => navItems.find((item) => item.url.endsWith("/automations")), [navItems] ); + const artifactsItem = useMemo( + () => navItems.find((item) => item.url.endsWith("/artifacts")), + [navItems] + ); const documentsItem = useMemo( () => navItems.find((item) => item.url === "#documents"), [navItems] @@ -153,7 +157,10 @@ export function Sidebar({ () => navItems.filter( (item) => - item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations") + item.url !== "#inbox" && + item.url !== "#documents" && + !item.url.endsWith("/automations") && + !item.url.endsWith("/artifacts") ), [navItems] ); @@ -242,6 +249,16 @@ export function Sidebar({ tooltipContent={isCollapsed ? automationsItem.title : undefined} /> )} + {artifactsItem && ( + onNavItemClick?.(artifactsItem)} + isCollapsed={isCollapsed} + isActive={artifactsItem.isActive} + tooltipContent={isCollapsed ? artifactsItem.title : undefined} + /> + )} {documentsItem && ( diff --git a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx index 5816d9616..0f2571d78 100644 --- a/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx +++ b/surfsense_web/components/tool-ui/video-presentation/generate-video-presentation.tsx @@ -485,7 +485,7 @@ function VideoPresentationPlayer({ ); } -function StatusPoller({ +export function StatusPoller({ presentationId, title, shareToken, diff --git a/surfsense_web/components/tool-ui/video-presentation/index.ts b/surfsense_web/components/tool-ui/video-presentation/index.ts index 7298a08ad..fbc982690 100644 --- a/surfsense_web/components/tool-ui/video-presentation/index.ts +++ b/surfsense_web/components/tool-ui/video-presentation/index.ts @@ -1 +1,4 @@ -export { GenerateVideoPresentationToolUI } from "./generate-video-presentation"; +export { + GenerateVideoPresentationToolUI, + StatusPoller as VideoPresentationViewer, +} from "./generate-video-presentation"; diff --git a/surfsense_web/contracts/types/image-generations.types.ts b/surfsense_web/contracts/types/image-generations.types.ts new file mode 100644 index 000000000..d972dad78 --- /dev/null +++ b/surfsense_web/contracts/types/image-generations.types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +// ============================================================================= +// Image generations — mirror app/schemas/image_generation.py. +// ============================================================================= + +export const imageGenerationListItem = z.object({ + id: z.number(), + prompt: z.string(), + search_space_id: z.number(), + created_at: z.string(), + is_success: z.boolean(), + image_count: z.number().nullish(), +}); +export type ImageGenerationListItem = z.infer; + +export const imageGenerationList = z.array(imageGenerationListItem); + +// Detail carries the raw provider response, which holds the displayable image +// as either a hosted url or inline base64. +export const imageGenerationDetail = z.object({ + id: z.number(), + prompt: z.string(), + response_data: z.record(z.string(), z.unknown()).nullish(), + error_message: z.string().nullish(), +}); +export type ImageGenerationDetail = z.infer; diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index 31311c469..365847668 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -155,3 +155,16 @@ export const podcastDetail = z.object({ thread_id: z.number().nullable(), }); export type PodcastDetail = z.infer; + +// Lightweight list item — mirror app/podcasts/api/schemas.py PodcastSummary. +export const podcastSummary = z.object({ + id: z.number(), + title: z.string(), + status: podcastStatus, + created_at: z.string(), + search_space_id: z.number(), + thread_id: z.number().nullish(), +}); +export type PodcastSummary = z.infer; + +export const podcastSummaryList = z.array(podcastSummary); diff --git a/surfsense_web/contracts/types/reports.types.ts b/surfsense_web/contracts/types/reports.types.ts new file mode 100644 index 000000000..8c7b1fe72 --- /dev/null +++ b/surfsense_web/contracts/types/reports.types.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +// ============================================================================= +// Reports — mirror app/schemas/reports.py ReportRead (list view, no content). +// Resumes are reports with content_type === "typst". +// ============================================================================= + +export const reportMetadata = z + .object({ + status: z.enum(["ready", "failed"]).nullish(), + word_count: z.number().nullish(), + }) + .nullish(); + +export const reportListItem = z.object({ + id: z.number(), + title: z.string(), + content_type: z.string().default("markdown"), + report_metadata: reportMetadata, + thread_id: z.number().nullish(), + created_at: z.string(), +}); +export type ReportListItem = z.infer; + +export const reportList = z.array(reportListItem); diff --git a/surfsense_web/contracts/types/video-presentations.types.ts b/surfsense_web/contracts/types/video-presentations.types.ts new file mode 100644 index 000000000..7e0603c75 --- /dev/null +++ b/surfsense_web/contracts/types/video-presentations.types.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +// ============================================================================= +// Video presentations — mirror app/schemas/video_presentations.py status enum. +// ============================================================================= + +export const videoPresentationStatus = z.enum(["pending", "generating", "ready", "failed"]); +export type VideoPresentationStatus = z.infer; + +export const videoPresentationListItem = z.object({ + id: z.number(), + title: z.string(), + status: videoPresentationStatus.default("ready"), + created_at: z.string(), + search_space_id: z.number(), + thread_id: z.number().nullish(), +}); +export type VideoPresentationListItem = z.infer; + +export const videoPresentationList = z.array(videoPresentationListItem); diff --git a/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts new file mode 100644 index 000000000..e9ed68633 --- /dev/null +++ b/surfsense_web/features/artifacts-library/hooks/use-library-artifacts.ts @@ -0,0 +1,98 @@ +import { useQuery } from "@tanstack/react-query"; +import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service"; +import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; +import { reportsApiService } from "@/lib/apis/reports-api.service"; +import { videoPresentationsApiService } from "@/lib/apis/video-presentations-api.service"; +import type { LibraryArtifact, LibraryArtifactStatus } from "../model/artifact"; + +function podcastStatus(status: string): LibraryArtifactStatus { + if (status === "ready") return "ready"; + if (status === "failed" || status === "cancelled") return "error"; + return "running"; +} + +function videoStatus(status: string): LibraryArtifactStatus { + if (status === "ready") return "ready"; + if (status === "failed") return "error"; + return "running"; +} + +// Each list is fetched independently; one failing source shouldn't blank the +// whole library, so failures degrade to an empty slice. +async function fetchLibraryArtifacts(searchSpaceId: number): Promise { + const [reports, podcasts, videos, images] = await Promise.all([ + reportsApiService.list(searchSpaceId).catch(() => []), + podcastsApiService.list(searchSpaceId).catch(() => []), + videoPresentationsApiService.list(searchSpaceId).catch(() => []), + imageGenerationsApiService.list(searchSpaceId).catch(() => []), + ]); + + const artifacts: LibraryArtifact[] = []; + + for (const report of reports) { + const isResume = report.content_type === "typst"; + artifacts.push({ + key: `report-${report.id}`, + kind: isResume ? "resume" : "report", + entityId: report.id, + title: report.title, + status: report.report_metadata?.status === "failed" ? "error" : "ready", + createdAt: report.created_at, + contentType: isResume ? "typst" : "markdown", + sourceThreadId: report.thread_id, + }); + } + + for (const podcast of podcasts) { + artifacts.push({ + key: `podcast-${podcast.id}`, + kind: "podcast", + entityId: podcast.id, + title: podcast.title, + status: podcastStatus(podcast.status), + createdAt: podcast.created_at, + contentType: "markdown", + sourceThreadId: podcast.thread_id, + }); + } + + for (const video of videos) { + artifacts.push({ + key: `video-${video.id}`, + kind: "video", + entityId: video.id, + title: video.title, + status: videoStatus(video.status), + createdAt: video.created_at, + contentType: "markdown", + sourceThreadId: video.thread_id, + }); + } + + for (const image of images) { + artifacts.push({ + key: `image-${image.id}`, + kind: "image", + entityId: image.id, + title: image.prompt, + status: image.is_success ? "ready" : "error", + createdAt: image.created_at, + contentType: "markdown", + }); + } + + return artifacts.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); +} + +export function useLibraryArtifacts(searchSpaceId: number) { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["artifacts-library", searchSpaceId], + queryFn: () => fetchLibraryArtifacts(searchSpaceId), + enabled: Number.isFinite(searchSpaceId) && searchSpaceId > 0, + staleTime: 60 * 1000, + }); + + return { artifacts: data ?? [], loading: isLoading, error, refresh: refetch }; +} diff --git a/surfsense_web/features/artifacts-library/index.ts b/surfsense_web/features/artifacts-library/index.ts new file mode 100644 index 000000000..f086f50ae --- /dev/null +++ b/surfsense_web/features/artifacts-library/index.ts @@ -0,0 +1 @@ +export { ArtifactsLibrary } from "./ui/artifacts-library"; diff --git a/surfsense_web/features/artifacts-library/model/artifact.ts b/surfsense_web/features/artifacts-library/model/artifact.ts new file mode 100644 index 000000000..d55751737 --- /dev/null +++ b/surfsense_web/features/artifacts-library/model/artifact.ts @@ -0,0 +1,23 @@ +/** Deliverable kinds surfaced in the search-space-wide artifacts library. */ +export type LibraryArtifactKind = "report" | "resume" | "podcast" | "video" | "image"; + +export type LibraryArtifactStatus = "ready" | "running" | "error"; + +/** + * A deliverable aggregated from the search space's list endpoints. The heavy + * content (report body, audio, video frames, image bytes) is fetched lazily by + * the viewer when a card is opened. + */ +export interface LibraryArtifact { + /** Stable list key — `${kind}-${entityId}`. */ + key: string; + kind: LibraryArtifactKind; + entityId: number; + title: string; + status: LibraryArtifactStatus; + createdAt: string; + /** Report panel content type — "typst" for resumes, "markdown" otherwise. */ + contentType: "markdown" | "typst"; + /** Chat thread that produced this artifact, when the source recorded one. */ + sourceThreadId?: number | null; +} diff --git a/surfsense_web/features/artifacts-library/ui/artifact-card.tsx b/surfsense_web/features/artifacts-library/ui/artifact-card.tsx new file mode 100644 index 000000000..c0ffd1f93 --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/artifact-card.tsx @@ -0,0 +1,63 @@ +import { MessageSquareText } from "lucide-react"; +import Link from "next/link"; +import { formatRelativeDate } from "@/lib/format-date"; +import type { LibraryArtifact } from "../model/artifact"; +import { KIND_META } from "./kind-meta"; + +export function ArtifactCard({ + artifact, + searchSpaceId, + onOpen, +}: { + artifact: LibraryArtifact; + searchSpaceId: number; + onOpen: (artifact: LibraryArtifact) => void; +}) { + const meta = KIND_META[artifact.kind]; + const Icon = meta.icon; + + const subtitle = + artifact.status === "running" + ? "Generating…" + : artifact.status === "error" + ? "Failed" + : meta.label; + + return ( +
+ {/* Stretched overlay makes the whole card open the viewer; sibling controls sit above it via z-10. */} + + + + + + + {artifact.title} + + + {subtitle} + + · + {formatRelativeDate(artifact.createdAt)} + + + + {artifact.sourceThreadId ? ( + + + Open source chat + + ) : null} +
+ ); +} diff --git a/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx new file mode 100644 index 000000000..3441f626e --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/artifacts-library.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { Boxes, RefreshCw, TriangleAlert } from "lucide-react"; +import { useMemo, useState } from "react"; +import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { MobileReportPanel } from "@/components/report-panel/report-panel"; +import { Button } from "@/components/ui/button"; +import { useLibraryArtifacts } from "../hooks/use-library-artifacts"; +import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact"; +import { ArtifactCard } from "./artifact-card"; +import { KIND_META, KIND_ORDER } from "./kind-meta"; +import { MediaViewerDialog } from "./media-viewer-dialog"; + +const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5", "s6"]; + +function LoadingState() { + return ( +
+ {SKELETON_KEYS.map((key) => ( +
+ ))} +
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+ + + +
+

Couldn't load artifacts

+

+ Something went wrong fetching this search space's deliverables. +

+
+ +
+ ); +} + +function EmptyState() { + return ( +
+ + + +
+

No artifacts yet

+

+ Reports, resumes, podcasts, presentations, and images you generate appear here. +

+
+
+ ); +} + +export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) { + const { artifacts, loading, error, refresh } = useLibraryArtifacts(searchSpaceId); + const openReportPanel = useSetAtom(openReportPanelAtom); + const [selectedMedia, setSelectedMedia] = useState(null); + + const grouped = useMemo(() => { + const map = new Map(); + for (const artifact of artifacts) { + const bucket = map.get(artifact.kind); + if (bucket) bucket.push(artifact); + else map.set(artifact.kind, [artifact]); + } + return map; + }, [artifacts]); + + const handleOpen = (artifact: LibraryArtifact) => { + // Reports/resumes reuse the shared report panel; the rest open in the dialog. + if (artifact.kind === "report" || artifact.kind === "resume") { + openReportPanel({ + reportId: artifact.entityId, + title: artifact.title, + contentType: artifact.contentType, + }); + return; + } + setSelectedMedia(artifact); + }; + + return ( +
+
+
+

Artifacts

+

+ Every deliverable created across this search space. +

+
+ {!loading && artifacts.length > 0 ? ( + {artifacts.length} total + ) : null} +
+ + {loading ? ( + + ) : error ? ( + refresh()} /> + ) : artifacts.length === 0 ? ( + + ) : ( +
+ {KIND_ORDER.map((kind) => { + const items = grouped.get(kind); + if (!items || items.length === 0) return null; + return ( +
+

+ {KIND_META[kind].group} + {items.length} +

+
+ {items.map((artifact) => ( + + ))} +
+
+ ); + })} +
+ )} + + setSelectedMedia(null)} /> + +
+ ); +} diff --git a/surfsense_web/features/artifacts-library/ui/kind-meta.ts b/surfsense_web/features/artifacts-library/ui/kind-meta.ts new file mode 100644 index 000000000..5241f812f --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/kind-meta.ts @@ -0,0 +1,16 @@ +import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react"; +import type { ComponentType } from "react"; +import type { LibraryArtifactKind } from "../model/artifact"; + +export const KIND_META: Record< + LibraryArtifactKind, + { icon: ComponentType<{ className?: string }>; label: string; group: string } +> = { + report: { icon: FileText, label: "Report", group: "Reports" }, + resume: { icon: Contact, label: "Resume", group: "Resumes" }, + podcast: { icon: AudioLines, label: "Podcast", group: "Podcasts" }, + video: { icon: Presentation, label: "Presentation", group: "Presentations" }, + image: { icon: ImageIcon, label: "Image", group: "Images" }, +}; + +export const KIND_ORDER: LibraryArtifactKind[] = ["report", "resume", "podcast", "video", "image"]; diff --git a/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx b/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx new file mode 100644 index 000000000..5509ec50b --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/library-image-viewer.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Image, ImageLoading } from "@/components/tool-ui/image"; +import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service"; + +function extractImageSrc(responseData: Record | null | undefined): string | null { + const data = (responseData as { data?: unknown } | null | undefined)?.data; + if (!Array.isArray(data) || data.length === 0) return null; + const first = data[0] as { url?: string; b64_json?: string }; + if (first?.url) return first.url; + if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`; + return null; +} + +export function LibraryImageViewer({ imageId, prompt }: { imageId: number; prompt: string }) { + const { data, isLoading, error } = useQuery({ + queryKey: ["image-generation-detail", imageId], + queryFn: () => imageGenerationsApiService.getDetail(imageId), + }); + + if (isLoading) return ; + + const src = extractImageSrc(data?.response_data); + if (error || !src) { + return ( +

+ {data?.error_message || "Image not available"} +

+ ); + } + + return ( + {prompt} + ); +} diff --git a/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx new file mode 100644 index 000000000..26954be02 --- /dev/null +++ b/surfsense_web/features/artifacts-library/ui/media-viewer-dialog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact"; +import { LibraryImageViewer } from "./library-image-viewer"; + +const ViewerFallback = () => ( +
+ +
+); + +const PodcastPlayer = dynamic( + () => import("@/components/tool-ui/podcast/player").then((m) => m.PodcastPlayer), + { ssr: false, loading: ViewerFallback } +); + +const VideoPresentationViewer = dynamic( + () => import("@/components/tool-ui/video-presentation").then((m) => m.VideoPresentationViewer), + { ssr: false, loading: ViewerFallback } +); + +// `stretch` overrides the players' inline-chat max-w/margins so they fill the dialog. +function dialogLayout(kind: LibraryArtifactKind): { width: string; stretch: boolean } { + if (kind === "video") return { width: "max-w-4xl", stretch: true }; + if (kind === "podcast") return { width: "max-w-2xl", stretch: true }; + return { width: "max-w-2xl", stretch: false }; +} + +function MediaViewerBody({ artifact }: { artifact: LibraryArtifact }) { + if (artifact.kind === "podcast") { + return ; + } + if (artifact.kind === "video") { + return ; + } + return ; +} + +/** + * Modal viewer for inline-media artifacts (podcast, video, image). Reports and + * resumes use the shared report panel instead and never reach this dialog. + */ +export function MediaViewerDialog({ + artifact, + onClose, +}: { + artifact: LibraryArtifact | null; + onClose: () => void; +}) { + const layout = artifact ? dialogLayout(artifact.kind) : null; + + return ( + { + if (!open) onClose(); + }} + > + + {artifact?.title ?? "Artifact"} + {artifact ? ( +
div]:!my-0 [&>div]:!max-w-none [&>div>*]:!max-w-none" + : "flex justify-center" + )} + > + +
+ ) : null} +
+
+ ); +} diff --git a/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts b/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts new file mode 100644 index 000000000..e7991d846 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/hooks/use-sync-chat-artifacts.ts @@ -0,0 +1,22 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { collectArtifacts } from "../lib/collect-artifacts"; +import { chatArtifactsAtom } from "../state/artifacts-panel.atom"; + +/** + * Keep `chatArtifactsAtom` in sync with the active thread's messages so the + * right-panel sidebar (rendered in the layout shell, outside the chat runtime) + * can read the deliverable list. Clears on unmount and on thread switch (a new + * `messages` array recomputes to the new thread's artifacts). + */ +export function useSyncChatArtifacts(messages: readonly ThreadMessageLike[]): void { + const setArtifacts = useSetAtom(chatArtifactsAtom); + const artifacts = useMemo(() => collectArtifacts(messages), [messages]); + + useEffect(() => { + setArtifacts(artifacts); + }, [artifacts, setArtifacts]); + + useEffect(() => () => setArtifacts([]), [setArtifacts]); +} diff --git a/surfsense_web/features/chat-artifacts/index.ts b/surfsense_web/features/chat-artifacts/index.ts new file mode 100644 index 000000000..f5c39a4a4 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/index.ts @@ -0,0 +1,14 @@ +export { useSyncChatArtifacts } from "./hooks/use-sync-chat-artifacts"; +export { collectArtifacts } from "./lib/collect-artifacts"; +export { ARTIFACT_ANCHOR_ATTR, scrollToArtifact } from "./lib/scroll-to-artifact"; +export type { ArtifactKind, ArtifactStatus, ChatArtifact } from "./model/artifact"; +export { + artifactsPanelOpenAtom, + chatArtifactsAtom, + closeArtifactsPanelAtom, + openArtifactsPanelAtom, + toggleArtifactsPanelAtom, +} from "./state/artifacts-panel.atom"; +export { withArtifactAnchor } from "./ui/artifact-anchor"; +export { ArtifactsPanelContent, MobileArtifactsPanel } from "./ui/artifacts-panel"; +export { ArtifactsToggleButton } from "./ui/artifacts-toggle-button"; diff --git a/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts new file mode 100644 index 000000000..1e01fda94 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts @@ -0,0 +1,139 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { + ARTIFACT_TOOL_KINDS, + type ArtifactKind, + type ArtifactStatus, + type ChatArtifact, +} from "../model/artifact"; + +interface ToolCallPart { + type: "tool-call"; + toolCallId: string; + toolName: string; + args?: Record; + result?: unknown; +} + +function isToolCallPart(part: unknown): part is ToolCallPart { + return ( + typeof part === "object" && + part !== null && + (part as { type?: unknown }).type === "tool-call" && + typeof (part as { toolCallId?: unknown }).toolCallId === "string" && + typeof (part as { toolName?: unknown }).toolName === "string" + ); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) return value; + } + return null; +} + +function numericId(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** Extracts entity id, title, and status for a single deliverable tool call. */ +function describeArtifact( + kind: ArtifactKind, + args: Record, + result: Record, + hasResult: boolean +): { title: string; entityId: number | null; status: ArtifactStatus } { + const resultStatus = typeof result.status === "string" ? result.status : null; + const failed = resultStatus === "failed" || resultStatus === "error" || !!result.error; + + switch (kind) { + case "report": { + const entityId = numericId(result.report_id); + return { + title: firstString(result.title, args.topic) ?? "Report", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "resume": { + const entityId = numericId(result.report_id); + return { + title: firstString(result.title) ?? "Resume", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "podcast": { + const entityId = numericId(result.podcast_id); + return { + title: firstString(result.title, args.podcast_title) ?? "Podcast", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "video": { + const entityId = numericId(result.video_presentation_id); + return { + title: firstString(result.title, args.video_title) ?? "Presentation", + entityId, + status: failed ? "error" : entityId != null ? "ready" : "running", + }; + } + case "image": { + const ready = typeof result.src === "string" && result.src.length > 0; + return { + title: firstString(result.title, args.prompt) ?? "Image", + entityId: null, + status: failed ? "error" : ready ? "ready" : hasResult ? "ready" : "running", + }; + } + } +} + +/** + * Aggregate the deliverable artifacts referenced across a thread's messages. + * + * Scans assistant tool-call parts, keeps recognized deliverable tools, and + * dedupes by backing entity (so a regenerated report collapses to one entry, + * refreshed in place to keep chronological order). Errored deliverables are + * dropped — they have nothing to open or jump to. + */ +export function collectArtifacts(messages: readonly ThreadMessageLike[]): ChatArtifact[] { + const byKey = new Map(); + + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + + for (const part of message.content) { + if (!isToolCallPart(part)) continue; + const kind = ARTIFACT_TOOL_KINDS[part.toolName]; + if (!kind) continue; + + const args = asRecord(part.args); + const result = asRecord(part.result); + const { title, entityId, status } = describeArtifact( + kind, + args, + result, + part.result !== undefined + ); + if (status === "error") continue; + + const key = entityId != null ? `${kind}:${entityId}` : part.toolCallId; + byKey.set(key, { + key, + kind, + title, + status, + toolCallId: part.toolCallId, + entityId, + contentType: kind === "resume" ? "typst" : "markdown", + }); + } + } + + return Array.from(byKey.values()); +} diff --git a/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts new file mode 100644 index 000000000..5a4ed2160 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/lib/scroll-to-artifact.ts @@ -0,0 +1,42 @@ +/** Data attribute stamped on each deliverable card wrapper by `ArtifactAnchor`. */ +export const ARTIFACT_ANCHOR_ATTR = "data-artifact-tool-call-id"; + +const HIGHLIGHT_CLASSES = ["ring-2", "ring-primary/60"]; +const HIGHLIGHT_DURATION_MS = 1600; +const RETRY_INTERVAL_MS = 120; +const MAX_WAIT_MS = 1500; + +function isInView(el: HTMLElement): boolean { + const { top, bottom } = el.getBoundingClientRect(); + return bottom > window.innerHeight * 0.2 && top < window.innerHeight * 0.8; +} + +/** + * Scroll the inline card for `toolCallId` into view and pulse a ring. Retries + * because the thread viewport's initialize auto-scroll can fire after the first + * jump and snap back to the bottom; scrolling off-bottom disengages it. + */ +export function scrollToArtifact(toolCallId: string): void { + if (typeof document === "undefined") return; + + const selector = `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]`; + const deadline = Date.now() + MAX_WAIT_MS; + let highlighted = false; + + const attempt = () => { + const anchor = document.querySelector(selector); + if (anchor) { + anchor.scrollIntoView({ behavior: "smooth", block: "center" }); + if (!highlighted) { + highlighted = true; + const card = (anchor.firstElementChild as HTMLElement | null) ?? anchor; + card.classList.add(...HIGHLIGHT_CLASSES); + window.setTimeout(() => card.classList.remove(...HIGHLIGHT_CLASSES), HIGHLIGHT_DURATION_MS); + } + if (isInView(anchor)) return; + } + if (Date.now() < deadline) window.setTimeout(attempt, RETRY_INTERVAL_MS); + }; + + attempt(); +} diff --git a/surfsense_web/features/chat-artifacts/model/artifact.ts b/surfsense_web/features/chat-artifacts/model/artifact.ts new file mode 100644 index 000000000..d8fff5bdd --- /dev/null +++ b/surfsense_web/features/chat-artifacts/model/artifact.ts @@ -0,0 +1,33 @@ +/** Deliverable kinds the agent can produce and surface in the artifacts sidebar. */ +export type ArtifactKind = "report" | "resume" | "podcast" | "video" | "image"; + +export type ArtifactStatus = "running" | "ready" | "error"; + +/** + * A chat deliverable, aggregated from the assistant message stream. One entry + * per deliverable tool call; the heavy content stays in the inline card and is + * fetched lazily by the panel/card on demand. + */ +export interface ChatArtifact { + /** Stable identity for list keys + dedupe — entity id when known, else the tool call id. */ + key: string; + kind: ArtifactKind; + title: string; + status: ArtifactStatus; + /** Anchors the scroll-to-card jump back into the conversation. */ + toolCallId: string; + /** Backing entity id for report/resume/podcast/video; null for images. */ + entityId: number | null; + /** Report panel content type — "typst" for resumes, "markdown" otherwise. */ + contentType: "markdown" | "typst"; +} + +/** Maps deliverable tool names to artifact kinds. Mirrors the body tools in assistant-message. */ +export const ARTIFACT_TOOL_KINDS: Record = { + generate_report: "report", + generate_resume: "resume", + generate_podcast: "podcast", + generate_video_presentation: "video", + generate_image: "image", + display_image: "image", +}; diff --git a/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts new file mode 100644 index 000000000..caa809d78 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/state/artifacts-panel.atom.ts @@ -0,0 +1,39 @@ +import { atom } from "jotai"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; +import type { ChatArtifact } from "../model/artifact"; + +/** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */ +export const chatArtifactsAtom = atom([]); + +/** Open === artifacts owns the tab; derived so the toggle can't drift. */ +export const artifactsPanelOpenAtom = atom((get) => get(rightPanelTabAtom) === "artifacts"); + +/** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */ +const preArtifactsCollapsedAtom = atom(null); + +export const openArtifactsPanelAtom = atom(null, (get, set) => { + if (get(rightPanelTabAtom) !== "artifacts") { + set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom)); + } + set(rightPanelTabAtom, "artifacts"); + set(rightPanelCollapsedAtom, false); +}); + +export const closeArtifactsPanelAtom = atom(null, (get, set) => { + // Don't clobber the tab when another surface owns it. + if (get(rightPanelTabAtom) !== "artifacts") return; + // RightPanel's fallback then re-reveals any surface underneath (e.g. a report). + set(rightPanelTabAtom, "sources"); + const prev = get(preArtifactsCollapsedAtom); + if (prev !== null) { + set(rightPanelCollapsedAtom, prev); + set(preArtifactsCollapsedAtom, null); + } +}); + +export const toggleArtifactsPanelAtom = atom(null, (get, set) => { + // Only close when artifacts is actually visible; otherwise a click always opens it. + const shown = get(rightPanelTabAtom) === "artifacts" && !get(rightPanelCollapsedAtom); + if (shown) set(closeArtifactsPanelAtom); + else set(openArtifactsPanelAtom); +}); diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx new file mode 100644 index 000000000..de5baa08c --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx @@ -0,0 +1,20 @@ +import type { ToolCallMessagePartComponent, ToolCallMessagePartProps } from "@assistant-ui/react"; +import { ARTIFACT_ANCHOR_ATTR } from "../lib/scroll-to-artifact"; + +/** + * Wrap a body tool component so its rendered card carries a DOM anchor keyed by + * tool call id. The artifacts sidebar uses it to scroll a deliverable back into + * view. The wrapper is layout-neutral — the card keeps its own margins. + */ +export function withArtifactAnchor( + Tool: ToolCallMessagePartComponent +): ToolCallMessagePartComponent { + function AnchoredTool(props: ToolCallMessagePartProps) { + return ( +
+ +
+ ); + } + return AnchoredTool; +} diff --git a/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx new file mode 100644 index 000000000..3bf2dbc0c --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifact-row.tsx @@ -0,0 +1,66 @@ +import { useSetAtom } from "jotai"; +import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react"; +import type { ComponentType } from "react"; +import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom"; +import { Button } from "@/components/ui/button"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { scrollToArtifact } from "../lib/scroll-to-artifact"; +import type { ArtifactKind, ChatArtifact } from "../model/artifact"; +import { closeArtifactsPanelAtom } from "../state/artifacts-panel.atom"; + +const KIND_META: Record< + ArtifactKind, + { icon: ComponentType<{ className?: string }>; label: string } +> = { + report: { icon: FileText, label: "Report" }, + resume: { icon: Contact, label: "Resume" }, + podcast: { icon: AudioLines, label: "Podcast" }, + video: { icon: Presentation, label: "Presentation" }, + image: { icon: ImageIcon, label: "Image" }, +}; + +export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) { + const openReportPanel = useSetAtom(openReportPanelAtom); + const closeArtifactsPanel = useSetAtom(closeArtifactsPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + const meta = KIND_META[artifact.kind]; + const Icon = meta.icon; + const isReportLike = artifact.kind === "report" || artifact.kind === "resume"; + + const handleOpen = () => { + // Reports/resumes open in the report viewer, which claims the tab itself. + if (isReportLike && artifact.entityId != null) { + openReportPanel({ + reportId: artifact.entityId, + title: artifact.title, + contentType: artifact.contentType, + }); + scrollToArtifact(artifact.toolCallId); + return; + } + + // Inline media has no viewer — just jump to the card. Mobile dismisses the + // drawer first since it covers the chat; desktop leaves the panel open. + if (!isDesktop) closeArtifactsPanel(); + scrollToArtifact(artifact.toolCallId); + }; + + return ( + + ); +} diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx new file mode 100644 index 000000000..7b3567d73 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { Boxes, XIcon } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import type { ArtifactKind, ChatArtifact } from "../model/artifact"; +import { + artifactsPanelOpenAtom, + chatArtifactsAtom, + closeArtifactsPanelAtom, +} from "../state/artifacts-panel.atom"; +import { ArtifactRow } from "./artifact-row"; + +const GROUP_ORDER: { kind: ArtifactKind; label: string }[] = [ + { kind: "report", label: "Reports" }, + { kind: "resume", label: "Resumes" }, + { kind: "podcast", label: "Podcasts" }, + { kind: "video", label: "Presentations" }, + { kind: "image", label: "Images" }, +]; + +function groupByKind(artifacts: ChatArtifact[]): { label: string; items: ChatArtifact[] }[] { + return GROUP_ORDER.map(({ kind, label }) => ({ + label, + items: artifacts.filter((a) => a.kind === kind), + })).filter((group) => group.items.length > 0); +} + +function EmptyState() { + return ( +
+ +

No artifacts yet

+

+ Reports, podcasts, presentations, and images you generate will appear here. +

+
+ ); +} + +function ArtifactGroups({ artifacts }: { artifacts: ChatArtifact[] }) { + const groups = useMemo(() => groupByKind(artifacts), [artifacts]); + + if (groups.length === 0) return ; + + return ( +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((artifact) => ( + + ))} +
+
+ ))} +
+ ); +} + +/** Inner content shared by the desktop right-panel tab and the mobile drawer. */ +export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) { + const artifacts = useAtomValue(chatArtifactsAtom); + + return ( + <> +
+

Artifacts

+ {onClose && ( + + )} +
+ + + ); +} + +/** + * Mobile artifacts drawer. Desktop renders inside the layout-level RightPanel + * tab instead, so this no-ops on large screens. + */ +export function MobileArtifactsPanel() { + const isOpen = useAtomValue(artifactsPanelOpenAtom); + const close = useSetAtom(closeArtifactsPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (isDesktop || !isOpen) return null; + + return ( + { + if (!open) close(); + }} + shouldScaleBackground={false} + > + + + Artifacts +
+ +
+
+
+ ); +} diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx new file mode 100644 index 000000000..be02c6956 --- /dev/null +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-toggle-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { Boxes } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + artifactsPanelOpenAtom, + chatArtifactsAtom, + toggleArtifactsPanelAtom, +} from "../state/artifacts-panel.atom"; + +/** Header toggle that opens the artifacts sidebar. Hidden when the thread has none. */ +export function ArtifactsToggleButton() { + const artifacts = useAtomValue(chatArtifactsAtom); + const isOpen = useAtomValue(artifactsPanelOpenAtom); + const toggle = useSetAtom(toggleArtifactsPanelAtom); + + if (artifacts.length === 0) return null; + + const label = isOpen ? "Hide artifacts" : "Show artifacts"; + + return ( + + + + + {label} + + ); +} diff --git a/surfsense_web/lib/apis/image-generations-api.service.ts b/surfsense_web/lib/apis/image-generations-api.service.ts new file mode 100644 index 000000000..6aa17854d --- /dev/null +++ b/surfsense_web/lib/apis/image-generations-api.service.ts @@ -0,0 +1,23 @@ +import { + imageGenerationDetail, + imageGenerationList, +} from "@/contracts/types/image-generations.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/image-generations"; + +class ImageGenerationsApiService { + list = async (searchSpaceId: number, limit = 100) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, imageGenerationList); + }; + + getDetail = async (imageGenId: number) => { + return baseApiService.get(`${BASE}/${imageGenId}`, imageGenerationDetail); + }; +} + +export const imageGenerationsApiService = new ImageGenerationsApiService(); diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index 2e13d63cc..3a18c7951 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -3,6 +3,7 @@ import { languageOptions, type PodcastSpec, podcastDetail, + podcastSummaryList, updateSpecRequest, voiceOption, } from "@/contracts/types/podcast.types"; @@ -14,6 +15,14 @@ const BASE = "/api/v1/podcasts"; const voiceOptionList = z.array(voiceOption); class PodcastsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, podcastSummaryList); + }; + // Full state including the deserialized brief and transcript; thin lifecycle // fields (status, spec, spec_version) also arrive live via Zero. getDetail = async (podcastId: number) => { diff --git a/surfsense_web/lib/apis/reports-api.service.ts b/surfsense_web/lib/apis/reports-api.service.ts new file mode 100644 index 000000000..bc4483f37 --- /dev/null +++ b/surfsense_web/lib/apis/reports-api.service.ts @@ -0,0 +1,16 @@ +import { reportList } from "@/contracts/types/reports.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/reports"; + +class ReportsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, reportList); + }; +} + +export const reportsApiService = new ReportsApiService(); diff --git a/surfsense_web/lib/apis/video-presentations-api.service.ts b/surfsense_web/lib/apis/video-presentations-api.service.ts new file mode 100644 index 000000000..ef3ac21ed --- /dev/null +++ b/surfsense_web/lib/apis/video-presentations-api.service.ts @@ -0,0 +1,16 @@ +import { videoPresentationList } from "@/contracts/types/video-presentations.types"; +import { baseApiService } from "./base-api.service"; + +const BASE = "/api/v1/video-presentations"; + +class VideoPresentationsApiService { + list = async (searchSpaceId: number, limit = 200) => { + const qs = new URLSearchParams({ + search_space_id: String(searchSpaceId), + limit: String(limit), + }).toString(); + return baseApiService.get(`${BASE}?${qs}`, videoPresentationList); + }; +} + +export const videoPresentationsApiService = new VideoPresentationsApiService();