"use client"; import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { ChevronDown, ChevronRight } from "lucide-react"; import type { ApiDataInterface } from "../../../../core"; import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry"; import type { AssistantMessageInterface } from "../../data/AssistantMessageInterface"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../../shadcnui/ui/tabs"; import { ReferencesTab } from "./tabs/ReferencesTab"; import { CitationsTab } from "./tabs/CitationsTab"; import { ContentsTab } from "./tabs/ContentsTab"; import { UsersTab } from "./tabs/UsersTab"; import { SuggestedQuestionsTab } from "./tabs/SuggestedQuestionsTab"; interface Props { message: AssistantMessageInterface; isLatestAssistant: boolean; onSelectFollowUp: (q: string) => void; /** * Map of resolved source entities keyed by `chunk.nodeId`. When provided, the * Contents and Citations tabs use these to render real entity names. The * library is presentational; the app fetches and supplies these. */ sources?: Map; /** * Deduplicated list of authors derived from the resolved sources. When * provided, the Users tab is populated from this list. */ users?: ApiDataInterface[]; } type TabKey = "suggested" | "references" | "citations" | "contents" | "users"; export function MessageSourcesPanel({ message, isLatestAssistant, onSelectFollowUp, sources, users }: Props) { const t = useTranslations(); // Filter out references whose module has no pageUrl: those are // intermediary/relational nodes (e.g., BomItem, BomTreeNode) that the user // shouldn't navigate to directly. const visibleReferences = useMemo( () => message.references.filter((ref) => { try { return !!ModuleRegistry.findByName(ref.type).pageUrl; } catch { return false; } }), [message.references], ); const refsCount = visibleReferences.length; const citationsCount = message.citations.length; const suggestionsCount = isLatestAssistant ? message.suggestedQuestions.length : 0; const contentsCount = useMemo(() => { // Count unique nodeIds across citations. Mirrors what ContentsTab actually // renders (one row per cited source), instead of `sources.size` which can // include entities the fetch returned but no citation references. const ids = new Set(); for (const c of message.citations) { if (!c.nodeId) continue; if (sources && !sources.has(c.nodeId)) continue; ids.add(c.nodeId); } return ids.size; }, [message.citations, sources]); const usersCount = users?.length ?? 0; const total = refsCount + citationsCount + contentsCount + usersCount + suggestionsCount; const visibleTabs: TabKey[] = []; if (suggestionsCount > 0) visibleTabs.push("suggested"); if (refsCount > 0) visibleTabs.push("references"); if (citationsCount > 0) visibleTabs.push("citations"); if (contentsCount > 0) visibleTabs.push("contents"); if (usersCount > 0) visibleTabs.push("users"); const autoOpen = isLatestAssistant && suggestionsCount > 0; const initialTab: TabKey | undefined = autoOpen ? "suggested" : visibleTabs[0]; const [open, setOpen] = useState(autoOpen); const [active, setActive] = useState(initialTab); if (total === 0) return null; const panelId = `sources-panel-${message.id}`; return (
{open && (
setActive(v as TabKey)}> {visibleTabs.map((key) => ( {t(`features.assistant.message.sources.tabs.${key === "suggested" ? "suggested_questions" : key}`)} {key === "suggested" && suggestionsCount} {key === "references" && refsCount} {key === "citations" && citationsCount} {key === "contents" && contentsCount} {key === "users" && usersCount} ))} {suggestionsCount > 0 && ( )} {refsCount > 0 && ( )} {citationsCount > 0 && ( )} {contentsCount > 0 && ( )} {usersCount > 0 && ( )}
)}
); }