import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { FileText, Menu, PanelRightOpen, X } from "lucide-react"; import { toast } from "sonner"; import KnowledgeManagement from "../KnowledgeManagement"; import { createAnalysisMessage, deleteAnalysis, getAnalysis, getAnalysisMessages, getDatabaseClients, getDocuments, listAnalyses, updateAnalysisDataBind, RESERVED_FAILED_MESSAGE_ID, type Analysis, type AnalysisMessage, type DataBindItem, } from "@/services/orchestrationApi"; import { AgenticApiError, getObservability, streamChat, streamHelp, type AgentStreamEvent } from "@/services/agenticApi"; import { AnalysisHeader } from "./AnalysisHeader"; import { AppNavigation, type AppMenuKey } from "./AppNavigation"; import { ChatInput } from "./ChatInput"; import { HomeDashboard } from "./HomeDashboard"; import { MessageList } from "./MessageList"; import { NewAnalysisDialog } from "./NewAnalysisDialog"; import { ReportSidebar } from "./ReportSidebar"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../ui/resizable"; import { SuggestedQuestionsBar } from "./SuggestedQuestionsBar"; import { clearSession, getCurrentSession } from "./session"; import type { StreamState, UiMessage } from "./types"; import { cx, makeLocalId } from "./utils"; function toUiMessage(message: AnalysisMessage): UiMessage { const failed = message.status === "failed"; return { id: message.id, role: message.role, content: message.content, created_at: message.created_at, status: failed ? "error" : "complete", statusText: failed ? message.note : undefined, messageId: message.message_id ?? undefined, persistStatus: message.status, persistNote: message.note, }; } function sortByUpdatedAtDesc(items: Analysis[]) { return [...items].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); } function normalizeAssistantContent(content: string) { return content .replace(/([A-Za-z0-9])(?=\*\*)/g, "$1 ") .replace(/(\*\*[^*\n]+\*\*)(?=[A-Za-z0-9])/g, "$1 ") .replace(/\*\*([^*\n]*?)\*\*/g, (_, inner) => `**${inner.trim()}**`) .trim(); } export function AnalysisShell() { const navigate = useNavigate(); const session = useMemo(() => getCurrentSession(), []); const [activeMenu, setActiveMenu] = useState("home"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileReportOpen, setMobileReportOpen] = useState(false); const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(false); const [analyses, setAnalyses] = useState([]); const [activeAnalysis, setActiveAnalysis] = useState(null); const [messages, setMessages] = useState([]); const [loadingAnalyses, setLoadingAnalyses] = useState(true); const [loadingMessages, setLoadingMessages] = useState(false); const [streamState, setStreamState] = useState("idle"); const [scrollTo, setScrollTo] = useState<"bottom" | "last-ai" | null>(null); const [newAnalysisOpen, setNewAnalysisOpen] = useState(false); const [reportCollapsed, setReportCollapsed] = useState(false); const [reportFullscreen, setReportFullscreen] = useState(false); const [reportWidthPercent, setReportWidthPercent] = useState(23); const [staleSources, setStaleSources] = useState([]); const selectAnalysis = async (analysis: Analysis) => { setActiveAnalysis(analysis); setLoadingMessages(true); try { const [detail, history] = await Promise.all([getAnalysis(analysis.id), getAnalysisMessages(analysis.id)]); setActiveAnalysis(detail); setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === detail.id ? detail : item)))); setMessages(history.map(toUiMessage)); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to open analysis"); setMessages([]); } finally { setLoadingMessages(false); } }; const loadAnalyses = async () => { setLoadingAnalyses(true); try { const result = await listAnalyses({ status: "active", page: 1, limit: 50 }); setAnalyses(sortByUpdatedAtDesc(result.analyses)); if (!activeAnalysis && result.analyses.length > 0) { await selectAnalysis(result.analyses[0]); } } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to load analyses"); } finally { setLoadingAnalyses(false); } }; useEffect(() => { loadAnalyses(); }, []); useEffect(() => { const checkStaleSources = async () => { if (!session?.user_id || !activeAnalysis?.data_bind.length) { setStaleSources([]); return; } try { const [documents, databaseClients] = await Promise.all([getDocuments(session.user_id), getDatabaseClients(session.user_id)]); const documentIds = new Set(documents.map((document) => document.id)); const databaseIds = new Set(databaseClients.map((client) => client.id)); setStaleSources( activeAnalysis.data_bind.filter((source) => { if (source.group_type === "document") return !documentIds.has(source.id); if (source.group_type === "database") return !databaseIds.has(source.id); return false; }) ); } catch { setStaleSources([]); } }; checkStaleSources(); }, [activeAnalysis?.id, activeAnalysis?.data_bind_version, activeMenu, session?.user_id]); const fetchTraceability = async (analysisId: string, messageId: string | undefined, uiMessageId: string) => { if (!messageId) return; setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceabilityLoading: true, traceabilityError: null } : msg))); try { const traceability = await getObservability(analysisId, messageId); setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceability, traceabilityLoading: false } : msg))); } catch (err) { const traceabilityError = err instanceof AgenticApiError && err.status === 404 ? "Traceability not available for this answer." : err instanceof Error ? err.message : "Failed to load traceability"; setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceabilityLoading: false, traceabilityError } : msg))); } }; useEffect(() => { if (!activeAnalysis?.id || loadingMessages) return; messages .filter( (message) => message.role === "ai" && message.status === "complete" && Boolean(message.messageId) && message.messageId !== RESERVED_FAILED_MESSAGE_ID && !message.traceability && !message.traceabilityLoading && !message.traceabilityError ) .forEach((message) => { fetchTraceability(activeAnalysis.id, message.messageId, message.id); }); }, [activeAnalysis?.id, loadingMessages, messages]); const runAgentStream = async (kind: "chat" | "help", prompt?: string) => { if (!session?.user_id || !activeAnalysis || streamState !== "idle") return; const aiId = makeLocalId(kind === "chat" ? "ai" : "help"); let finalContent = ""; let doneMessageId: string | undefined; let streamError: string | null = null; setMessages((prev) => [ ...prev, { id: aiId, role: "ai", content: "", created_at: new Date().toISOString(), status: "streaming", statusText: kind === "chat" ? "Reading analysis context" : "Preparing help", }, ]); setScrollTo("bottom"); setStreamState(kind === "chat" ? "streaming-chat" : "streaming-help"); try { const onEvent = (event: AgentStreamEvent) => { if (event.type === "chunk") { finalContent += event.data; setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, content: finalContent, statusText: undefined } : msg))); } if (event.type === "status") { setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, statusText: event.data } : msg))); } if (event.type === "sources") { setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, sources: event.data } : msg))); } if (event.type === "done") { doneMessageId = typeof event.data.message_id === "string" ? event.data.message_id : undefined; } if (event.type === "error") { streamError = event.data; setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, status: "error", statusText: event.data } : msg))); } }; if (kind === "chat" && prompt) { await streamChat({ user_id: session.user_id, analysis_id: activeAnalysis.id, message: prompt }, onEvent); } else { await streamHelp({ user_id: session.user_id, analysis_id: activeAnalysis.id }, onEvent); } if (streamError) return; const completedContent = normalizeAssistantContent(finalContent); let traceTargetId = aiId; let resolvedMessageId = doneMessageId; setScrollTo("last-ai"); setMessages((prev) => prev.map((msg) => msg.id === aiId ? { ...msg, content: completedContent || msg.content, status: "complete", messageId: doneMessageId, traceabilityLoading: Boolean(doneMessageId) } : msg ) ); if (completedContent) { const saved = await createAnalysisMessage(activeAnalysis.id, { role: "ai", content: completedContent, message_id: doneMessageId }); traceTargetId = saved.id; const savedUi = toUiMessage(saved); const persistFailed = saved.status === "failed"; resolvedMessageId = persistFailed ? undefined : savedUi.messageId ?? doneMessageId; setMessages((prev) => prev.map((msg) => msg.id === aiId ? { ...savedUi, sources: msg.sources, traceabilityLoading: Boolean(resolvedMessageId) } : msg ) ); if (persistFailed) { toast.error(saved.note || "Failed to save AI response"); return; } } await fetchTraceability(activeAnalysis.id, resolvedMessageId, traceTargetId); } catch (err) { const message = err instanceof Error ? err.message : "Agent stream failed"; setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, status: "error", statusText: message, traceabilityLoading: false } : msg))); } finally { setStreamState("idle"); } }; const handleSend = async (content: string) => { if (!activeAnalysis || !session?.user_id || streamState !== "idle") return; const localId = makeLocalId("user"); setMessages((prev) => [ ...prev, { id: localId, role: "user", content, created_at: new Date().toISOString(), status: "sending" }, ]); try { const saved = await createAnalysisMessage(activeAnalysis.id, { role: "user", content }); setMessages((prev) => prev.map((msg) => (msg.id === localId ? toUiMessage(saved) : msg))); } catch (err) { setMessages((prev) => prev.map((msg) => (msg.id === localId ? { ...msg, status: "error", statusText: "Failed to save message" } : msg))); toast.error(err instanceof Error ? err.message : "Failed to save message"); return; } await runAgentStream("chat", content); }; const handleHelp = async () => { await runAgentStream("help"); }; const handleCreated = (analysis: Analysis) => { setAnalyses((prev) => sortByUpdatedAtDesc([analysis, ...prev.filter((item) => item.id !== analysis.id)])); setActiveMenu("analysis-agent"); selectAnalysis(analysis); }; const handleDelete = async (analysis: Analysis) => { if (!window.confirm(`Delete analysis "${analysis.analysis_title}"?`)) return; try { await deleteAnalysis(analysis.id); const next = analyses.filter((item) => item.id !== analysis.id); setAnalyses(next); if (activeAnalysis?.id === analysis.id) { setActiveAnalysis(null); setMessages([]); setStaleSources([]); if (next[0]) selectAnalysis(next[0]); } } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to delete analysis"); } }; const handleUpdateDataBind = async (items: DataBindItem[]) => { if (!activeAnalysis) return; const updated = await updateAnalysisDataBind(activeAnalysis.id, activeAnalysis.data_bind_version, items); setActiveAnalysis(updated); setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === updated.id ? updated : item)))); setStaleSources([]); }; const logout = () => { clearSession(); navigate("/login", { replace: true }); }; const changeMenu = (menu: AppMenuKey) => { setActiveMenu(menu); setMobileNavOpen(false); setMobileReportOpen(false); }; const activeMenuLabel = activeMenu === "analysis-agent" ? "Analysis Agent" : activeMenu === "knowledge" ? "Knowledge" : "Home"; const chatPane = (
{loadingMessages ? (
Loading messages
) : ( setScrollTo(null)} businessQuestions={activeAnalysis?.business_questions ?? []} onSelectQuestion={handleSend} /> )}
{messages.length > 0 && ( )}
); const reportPane = reportCollapsed ? (
) : (
setReportCollapsed(true)} fullscreen={reportFullscreen} onToggleFullscreen={() => setReportFullscreen((value) => !value)} />
); const renderAnalysisAgent = () => ( {chatPane} {/* Report pane + handle are desktop-only; mobile reaches the report via the bottom-sheet rendered further below. */} {!reportFullscreen && } { if (!reportCollapsed && !reportFullscreen) setReportWidthPercent(size); }} className="hidden min-h-0 lg:block" > {reportPane} ); return (

{activeMenuLabel}

Data Eyond

{activeMenu === "analysis-agent" ? ( ) : (
)}
{mobileNavOpen && (
)} {mobileReportOpen && activeMenu === "analysis-agent" && (
)}
setDesktopNavCollapsed((value) => !value)} onNewAnalysis={() => setNewAnalysisOpen(true)} onSelectAnalysis={selectAnalysis} onDeleteAnalysis={handleDelete} />
{activeMenu === "home" && 0} onNavigate={changeMenu} />} {activeMenu === "knowledge" && changeMenu("home")} variant="page" />} {activeMenu === "analysis-agent" && renderAnalysisAgent()}
setNewAnalysisOpen(false)} onCreated={handleCreated} /> ); }