diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index e65388ada..3a788fba8 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext"; import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; +import { ChatbotProvider } from "@app/contexts/ChatbotContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; @@ -98,7 +99,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - {children} + + {children} + diff --git a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx new file mode 100644 index 000000000..4610a3d64 --- /dev/null +++ b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx @@ -0,0 +1,508 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionIcon, + Alert, + Badge, + Box, + Button, + Divider, + Drawer, + Group, + Modal, + ScrollArea, + Select, + Stack, + Switch, + Text, + Textarea, + Tooltip, +} from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; +import { useTranslation } from 'react-i18next'; +import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; +import SendRoundedIcon from '@mui/icons-material/SendRounded'; +import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; + +import { useChatbot } from '@app/contexts/ChatbotContext'; +import { useFileState } from '@app/contexts/FileContext'; +import { extractTextFromPdf } from '@app/services/pdfTextExtractor'; +import { runOcrForChat } from '@app/services/chatbotOcrService'; +import { + ChatbotMessageResponse, + ChatbotSessionInfo, + createChatbotSession, + sendChatbotPrompt, +} from '@app/services/chatbotService'; +import { useToast } from '@app/components/toast'; +import type { StirlingFile } from '@app/types/fileContext'; + +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + confidence?: number; + modelUsed?: string; + createdAt: Date; +} + +const ALPHA_ACK_KEY = 'stirling.chatbot.alphaAck'; + +function createMessageId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; +} + +const MAX_PROMPT_CHARS = 4000; + +const ChatbotDrawer = () => { + const { t } = useTranslation(); + const isMobile = useMediaQuery('(max-width: 768px)'); + const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot(); + const { selectors } = useFileState(); + const { show } = useToast(); + const files = selectors.getFiles(); + const [selectedFileId, setSelectedFileId] = useState(); + const [alphaAccepted, setAlphaAccepted] = useState(false); + const [runOcr, setRunOcr] = useState(false); + const [allowEscalation, setAllowEscalation] = useState(true); + const [isStartingSession, setIsStartingSession] = useState(false); + const [isSendingMessage, setIsSendingMessage] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const [sessionInfo, setSessionInfo] = useState(null); + const [contextStats, setContextStats] = useState<{ pageCount: number; characterCount: number } | null>(null); + const [messages, setMessages] = useState([]); + const [prompt, setPrompt] = useState(''); + const [warnings, setWarnings] = useState([]); + const [noTextModalOpen, setNoTextModalOpen] = useState(false); + const [pendingOcrRetry, setPendingOcrRetry] = useState(false); + const scrollViewportRef = useRef(null); + + const selectedFile = useMemo( + () => files.find((file) => file.fileId === selectedFileId), + [files, selectedFileId] + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + const storedAck = typeof window !== 'undefined' + ? window.localStorage.getItem(ALPHA_ACK_KEY) === 'true' + : false; + setAlphaAccepted(storedAck); + + if (preferredFileId) { + setSelectedFileId(preferredFileId); + setPreferredFileId(undefined); + return; + } + + if (!selectedFileId && files.length > 0) { + setSelectedFileId(files[0].fileId); + } + }, [isOpen, preferredFileId, setPreferredFileId, files, selectedFileId]); + + useEffect(() => { + if (!isOpen) { + return; + } + if (scrollViewportRef.current) { + scrollViewportRef.current.scrollTo({ + top: scrollViewportRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }, [messages, isOpen]); + + useEffect(() => { + if (sessionInfo && sessionInfo.documentId !== selectedFileId) { + setSessionInfo(null); + setContextStats(null); + setMessages([]); + setWarnings([]); + } + }, [sessionInfo, selectedFileId]); + + const handleAlphaAccept = (checked: boolean) => { + setAlphaAccepted(checked); + if (typeof window !== 'undefined') { + if (checked) { + window.localStorage.setItem(ALPHA_ACK_KEY, 'true'); + } else { + window.localStorage.removeItem(ALPHA_ACK_KEY); + } + } + }; + + const withStatus = async (label: string, fn: () => Promise): Promise => { + setStatusMessage(label); + try { + return await fn(); + } finally { + setStatusMessage(''); + } + }; + + const ensureFileSelected = () => { + if (!selectedFile) { + show({ + alertType: 'warning', + title: t('chatbot.toasts.noFileTitle', 'No PDF selected'), + body: t('chatbot.toasts.noFileBody', 'Please choose a document before starting the chatbot.'), + }); + return false; + } + return true; + }; + + const handleSessionStart = async (forceOcr?: boolean) => { + if (!ensureFileSelected() || !selectedFile) { + return; + } + if (!alphaAccepted) { + show({ + alertType: 'neutral', + title: t('chatbot.toasts.ackTitle', 'Accept alpha notice'), + body: t('chatbot.toasts.ackBody', 'Please acknowledge the alpha warning before starting.'), + }); + return; + } + setIsStartingSession(true); + try { + let workingFile: File = selectedFile; + const shouldRunOcr = forceOcr ?? runOcr; + + const extractionResult = await withStatus( + shouldRunOcr + ? t('chatbot.status.runningOcr', 'Running OCR and extracting text…') + : t('chatbot.status.extracting', 'Extracting text from PDF…'), + async () => { + if (shouldRunOcr) { + workingFile = await runOcrForChat(selectedFile); + } + return extractTextFromPdf(workingFile); + } + ); + + if (!extractionResult.text || extractionResult.text.trim().length === 0) { + setPendingOcrRetry(true); + setNoTextModalOpen(true); + return; + } + + const metadata = { + name: workingFile.name, + size: String(workingFile.size), + pageCount: String(extractionResult.pageCount), + }; + + const sessionPayload = { + sessionId: sessionInfo?.sessionId, + documentId: selectedFile.fileId, + text: extractionResult.text, + metadata, + ocrRequested: shouldRunOcr, + warningsAccepted: alphaAccepted, + }; + + const response = await withStatus( + t('chatbot.status.syncing', 'Syncing document with Stirling Bot…'), + () => createChatbotSession(sessionPayload) + ); + + setSessionInfo(response); + setContextStats({ + pageCount: extractionResult.pageCount, + characterCount: extractionResult.characterCount, + }); + setMessages([]); + setWarnings(response.warnings ?? []); + setPendingOcrRetry(false); + setNoTextModalOpen(false); + } catch (error) { + console.error('[Chatbot] Failed to start session', error); + show({ + alertType: 'error', + title: t('chatbot.toasts.failedSessionTitle', 'Could not prepare document'), + body: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsStartingSession(false); + setStatusMessage(''); + } + }; + + const handleSendMessage = async () => { + if (!sessionInfo) { + show({ + alertType: 'neutral', + title: t('chatbot.toasts.noSessionTitle', 'Sync your document first'), + body: t('chatbot.toasts.noSessionBody', 'Send your PDF to the chatbot before asking questions.'), + }); + return; + } + if (!prompt.trim()) { + return; + } + const trimmedPrompt = prompt.slice(0, MAX_PROMPT_CHARS); + const userMessage: ChatMessage = { + id: createMessageId(), + role: 'user', + content: trimmedPrompt, + createdAt: new Date(), + }; + setMessages((prev) => [...prev, userMessage]); + setPrompt(''); + setIsSendingMessage(true); + + try { + const reply = await sendChatbotPrompt({ + sessionId: sessionInfo.sessionId, + prompt: trimmedPrompt, + allowEscalation, + }); + setWarnings(reply.warnings ?? []); + const assistant = convertAssistantMessage(reply); + setMessages((prev) => [...prev, assistant]); + } catch (error) { + console.error('[Chatbot] Failed to send prompt', error); + show({ + alertType: 'error', + title: t('chatbot.toasts.failedPromptTitle', 'Unable to ask question'), + body: error instanceof Error ? error.message : String(error), + }); + // Revert optimistic user message + setMessages((prev) => prev.filter((message) => message.id !== userMessage.id)); + } finally { + setIsSendingMessage(false); + } + }; + + const convertAssistantMessage = (reply: ChatbotMessageResponse): ChatMessage => ({ + id: createMessageId(), + role: 'assistant', + content: reply.answer, + confidence: reply.confidence, + modelUsed: reply.modelUsed, + createdAt: new Date(), + }); + + const fileOptions = useMemo( + () => + files.map((file) => ({ + value: file.fileId, + label: `${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`, + })), + [files] + ); + + const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage; + + const drawerTitle = ( + + + {t('chatbot.title', 'Stirling PDF Bot')} + {t('chatbot.alphaBadge', 'Alpha')} + + ); + + const assistantWarnings = warnings.filter(Boolean); + + return ( + + + } + title={t('chatbot.alphaTitle', 'Experimental feature')} + > + {t('chatbot.alphaDescription', 'This chatbot is in alpha. It currently ignores images and may produce inaccurate answers. Your PDF text stays local until you confirm you want to chat.')} + + + handleAlphaAccept(event.currentTarget.checked)} + /> + +