mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +02:00
Merge pull request #1532 from CREDO23/imporve-artifacts-accessibility
[Feat] Artifacts sidebar for chat deliverables
This commit is contained in:
commit
efa9efc80b
40 changed files with 1306 additions and 43 deletions
|
|
@ -84,6 +84,7 @@ class PodcastSummary(BaseModel):
|
|||
status: PodcastStatus
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
thread_id: int | None = None
|
||||
|
||||
|
||||
class PodcastDetail(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <ArtifactsLibrary searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
<MobileArtifactsPanel />
|
||||
</div>
|
||||
</PendingInterruptProvider>
|
||||
<EditMessageDialog
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit" | "citation";
|
||||
export type RightPanelTab =
|
||||
| "sources"
|
||||
| "report"
|
||||
| "editor"
|
||||
| "hitl-edit"
|
||||
| "citation"
|
||||
| "artifacts";
|
||||
|
||||
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
|
||||
{hasThread && <ArtifactsToggleButton />}
|
||||
{threadForButton && <ChatShareButton thread={threadForButton} />}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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, boolean>
|
||||
): 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({
|
|||
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "artifacts" && artifactsOpen && (
|
||||
<div className="h-full flex flex-col">
|
||||
<ArtifactsPanelContent onClose={closeArtifacts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<SidebarButton
|
||||
icon={artifactsItem.icon}
|
||||
label={artifactsItem.title}
|
||||
onClick={() => onNavItemClick?.(artifactsItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={artifactsItem.isActive}
|
||||
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{documentsItem && (
|
||||
<SidebarButton
|
||||
icon={documentsItem.icon}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ export function CombinedPlayer({ slides }: CombinedPlayerProps) {
|
|||
compositionHeight={1080}
|
||||
style={{ width: "100%", aspectRatio: "16/9" }}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
acknowledgeRemotionLicense
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -485,7 +485,7 @@ function VideoPresentationPlayer({
|
|||
);
|
||||
}
|
||||
|
||||
function StatusPoller({
|
||||
export function StatusPoller({
|
||||
presentationId,
|
||||
title,
|
||||
shareToken,
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
export { GenerateVideoPresentationToolUI } from "./generate-video-presentation";
|
||||
export {
|
||||
GenerateVideoPresentationToolUI,
|
||||
StatusPoller as VideoPresentationViewer,
|
||||
} from "./generate-video-presentation";
|
||||
|
|
|
|||
27
surfsense_web/contracts/types/image-generations.types.ts
Normal file
27
surfsense_web/contracts/types/image-generations.types.ts
Normal file
|
|
@ -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<typeof imageGenerationListItem>;
|
||||
|
||||
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<typeof imageGenerationDetail>;
|
||||
|
|
@ -155,3 +155,16 @@ export const podcastDetail = z.object({
|
|||
thread_id: z.number().nullable(),
|
||||
});
|
||||
export type PodcastDetail = z.infer<typeof podcastDetail>;
|
||||
|
||||
// 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<typeof podcastSummary>;
|
||||
|
||||
export const podcastSummaryList = z.array(podcastSummary);
|
||||
|
|
|
|||
25
surfsense_web/contracts/types/reports.types.ts
Normal file
25
surfsense_web/contracts/types/reports.types.ts
Normal file
|
|
@ -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<typeof reportListItem>;
|
||||
|
||||
export const reportList = z.array(reportListItem);
|
||||
20
surfsense_web/contracts/types/video-presentations.types.ts
Normal file
20
surfsense_web/contracts/types/video-presentations.types.ts
Normal file
|
|
@ -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<typeof videoPresentationStatus>;
|
||||
|
||||
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<typeof videoPresentationListItem>;
|
||||
|
||||
export const videoPresentationList = z.array(videoPresentationListItem);
|
||||
|
|
@ -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<LibraryArtifact[]> {
|
||||
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 };
|
||||
}
|
||||
1
surfsense_web/features/artifacts-library/index.ts
Normal file
1
surfsense_web/features/artifacts-library/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ArtifactsLibrary } from "./ui/artifacts-library";
|
||||
23
surfsense_web/features/artifacts-library/model/artifact.ts
Normal file
23
surfsense_web/features/artifacts-library/model/artifact.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="group relative flex items-start gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40 hover:bg-accent/50">
|
||||
{/* Stretched overlay makes the whole card open the viewer; sibling controls sit above it via z-10. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(artifact)}
|
||||
className="absolute inset-0 rounded-xl"
|
||||
>
|
||||
<span className="sr-only">Open {artifact.title}</span>
|
||||
</button>
|
||||
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{artifact.title}</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={artifact.status === "error" ? "text-destructive" : undefined}>
|
||||
{subtitle}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatRelativeDate(artifact.createdAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{artifact.sourceThreadId ? (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/new-chat/${artifact.sourceThreadId}`}
|
||||
title="Open source chat"
|
||||
className="relative z-10 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<MessageSquareText className="size-4" />
|
||||
<span className="sr-only">Open source chat</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{SKELETON_KEYS.map((key) => (
|
||||
<div key={key} className="h-[68px] animate-pulse rounded-xl border bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-20 text-center">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<TriangleAlert className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Couldn't load artifacts</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Something went wrong fetching this search space's deliverables.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-20 text-center">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Boxes className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">No artifacts yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Reports, resumes, podcasts, presentations, and images you generate appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) {
|
||||
const { artifacts, loading, error, refresh } = useLibraryArtifacts(searchSpaceId);
|
||||
const openReportPanel = useSetAtom(openReportPanelAtom);
|
||||
const [selectedMedia, setSelectedMedia] = useState<LibraryArtifact | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<LibraryArtifactKind, LibraryArtifact[]>();
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||
<header className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Artifacts</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Every deliverable created across this search space.
|
||||
</p>
|
||||
</div>
|
||||
{!loading && artifacts.length > 0 ? (
|
||||
<span className="shrink-0 text-sm text-muted-foreground">{artifacts.length} total</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState onRetry={() => refresh()} />
|
||||
) : artifacts.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{KIND_ORDER.map((kind) => {
|
||||
const items = grouped.get(kind);
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<section key={kind}>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">
|
||||
{KIND_META[kind].group}
|
||||
<span className="ml-1.5 text-muted-foreground/60">{items.length}</span>
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((artifact) => (
|
||||
<ArtifactCard
|
||||
key={artifact.key}
|
||||
artifact={artifact}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaViewerDialog artifact={selectedMedia} onClose={() => setSelectedMedia(null)} />
|
||||
<MobileReportPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
surfsense_web/features/artifacts-library/ui/kind-meta.ts
Normal file
16
surfsense_web/features/artifacts-library/ui/kind-meta.ts
Normal file
|
|
@ -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"];
|
||||
|
|
@ -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<string, unknown> | 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 <ImageLoading title="Loading image" maxWidth="640px" />;
|
||||
|
||||
const src = extractImageSrc(data?.response_data);
|
||||
if (error || !src) {
|
||||
return (
|
||||
<p className="px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
{data?.error_message || "Image not available"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
id={`library-image-${imageId}`}
|
||||
assetId={String(imageId)}
|
||||
src={src}
|
||||
alt={prompt}
|
||||
title={prompt}
|
||||
domain="ai-generated"
|
||||
ratio="auto"
|
||||
maxWidth="640px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
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 <PodcastPlayer podcastId={artifact.entityId} title={artifact.title} />;
|
||||
}
|
||||
if (artifact.kind === "video") {
|
||||
return <VideoPresentationViewer presentationId={artifact.entityId} title={artifact.title} />;
|
||||
}
|
||||
return <LibraryImageViewer imageId={artifact.entityId} prompt={artifact.title} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Dialog
|
||||
open={artifact !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
// pt-12 keeps content clear of the absolute top-right close button.
|
||||
"flex max-h-[88vh] w-[95vw] flex-col overflow-y-auto pt-12",
|
||||
layout?.width ?? "max-w-2xl"
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">{artifact?.title ?? "Artifact"}</DialogTitle>
|
||||
{artifact ? (
|
||||
<div
|
||||
className={cn(
|
||||
layout?.stretch
|
||||
? "w-full [&>div]:!my-0 [&>div]:!max-w-none [&>div>*]:!max-w-none"
|
||||
: "flex justify-center"
|
||||
)}
|
||||
>
|
||||
<MediaViewerBody artifact={artifact} />
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
14
surfsense_web/features/chat-artifacts/index.ts
Normal file
14
surfsense_web/features/chat-artifacts/index.ts
Normal file
|
|
@ -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";
|
||||
139
surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts
Normal file
139
surfsense_web/features/chat-artifacts/lib/collect-artifacts.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
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<string, ChatArtifact>();
|
||||
|
||||
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());
|
||||
}
|
||||
|
|
@ -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<HTMLElement>(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();
|
||||
}
|
||||
33
surfsense_web/features/chat-artifacts/model/artifact.ts
Normal file
33
surfsense_web/features/chat-artifacts/model/artifact.ts
Normal file
|
|
@ -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<string, ArtifactKind> = {
|
||||
generate_report: "report",
|
||||
generate_resume: "resume",
|
||||
generate_podcast: "podcast",
|
||||
generate_video_presentation: "video",
|
||||
generate_image: "image",
|
||||
display_image: "image",
|
||||
};
|
||||
|
|
@ -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<ChatArtifact[]>([]);
|
||||
|
||||
/** 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<boolean | null>(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);
|
||||
});
|
||||
20
surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx
Normal file
20
surfsense_web/features/chat-artifacts/ui/artifact-anchor.tsx
Normal file
|
|
@ -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 (
|
||||
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }}>
|
||||
<Tool {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return AnchoredTool;
|
||||
}
|
||||
66
surfsense_web/features/chat-artifacts/ui/artifact-row.tsx
Normal file
66
surfsense_web/features/chat-artifacts/ui/artifact-row.tsx
Normal file
|
|
@ -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 (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleOpen}
|
||||
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left font-normal hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{artifact.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{artifact.status === "running" ? "Generating…" : meta.label}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
123
surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx
Normal file
123
surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center select-none">
|
||||
<Boxes className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground">No artifacts yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reports, podcasts, presentations, and images you generate will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactGroups({ artifacts }: { artifacts: ChatArtifact[] }) {
|
||||
const groups = useMemo(() => groupByKind(artifacts), [artifacts]);
|
||||
|
||||
if (groups.length === 0) return <EmptyState />;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="mb-3 last:mb-0">
|
||||
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80 select-none">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((artifact) => (
|
||||
<ArtifactRow key={artifact.key} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner content shared by the desktop right-panel tab and the mobile drawer. */
|
||||
export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) {
|
||||
const artifacts = useAtomValue(chatArtifactsAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
|
||||
<h2 className="select-none text-lg font-semibold">Artifacts</h2>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close artifacts panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ArtifactGroups artifacts={artifacts} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) close();
|
||||
}}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent
|
||||
className="h-[85vh] max-h-[85vh] z-80 overflow-hidden bg-sidebar"
|
||||
overlayClassName="z-80"
|
||||
>
|
||||
<DrawerHandle />
|
||||
<DrawerTitle className="sr-only">Artifacts</DrawerTitle>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ArtifactsPanelContent onClose={close} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggle()}
|
||||
aria-pressed={isOpen}
|
||||
className={cn(
|
||||
"relative h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
isOpen && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Boxes className="h-4 w-4" />
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground tabular-nums">
|
||||
{artifacts.length}
|
||||
</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
23
surfsense_web/lib/apis/image-generations-api.service.ts
Normal file
23
surfsense_web/lib/apis/image-generations-api.service.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
16
surfsense_web/lib/apis/reports-api.service.ts
Normal file
16
surfsense_web/lib/apis/reports-api.service.ts
Normal file
|
|
@ -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();
|
||||
16
surfsense_web/lib/apis/video-presentations-api.service.ts
Normal file
16
surfsense_web/lib/apis/video-presentations-api.service.ts
Normal file
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue