From a8d105430fbff0314548408d1930f2ca36898b4e Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 4 Nov 2025 17:44:34 +0000 Subject: [PATCH] chatbot UI --- .../public/locales/en-US/translation.json | 52 ++ frontend/src/core/components/AppProviders.tsx | 5 +- .../core/components/chatbot/ChatbotDrawer.tsx | 508 ++++++++++++++++++ .../viewer/useViewerRightRailButtons.tsx | 36 +- frontend/src/core/contexts/ChatbotContext.tsx | 61 +++ .../core/data/useTranslatedToolRegistry.tsx | 13 + frontend/src/core/pages/HomePage.tsx | 2 + .../src/core/services/chatbotOcrService.ts | 65 +++ frontend/src/core/services/chatbotService.ts | 51 ++ .../src/core/services/pdfTextExtractor.ts | 39 ++ frontend/src/core/tools/ChatbotAssistant.tsx | 46 ++ frontend/src/core/types/toolId.ts | 2 +- 12 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 frontend/src/core/components/chatbot/ChatbotDrawer.tsx create mode 100644 frontend/src/core/contexts/ChatbotContext.tsx create mode 100644 frontend/src/core/services/chatbotOcrService.ts create mode 100644 frontend/src/core/services/chatbotService.ts create mode 100644 frontend/src/core/services/pdfTextExtractor.ts create mode 100644 frontend/src/core/tools/ChatbotAssistant.tsx diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 2fa6fab55..acc16c78f 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -5467,5 +5467,57 @@ }, "replaceColorPdf": { "tags": "Replace Color,Page operations,Back end,server side" + }, + "chatbot": { + "title": "Stirling PDF Bot", + "alphaBadge": "Alpha", + "alphaTitle": "Experimental feature", + "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.", + "acceptAlphaLabel": "I understand this feature is experimental and image content is not supported yet.", + "fileLabel": "Document to query", + "filePlaceholder": "Select an uploaded PDF", + "noFiles": "Upload a PDF from File Manager to start chatting.", + "ocrToggle": "Run OCR before extracting text (uses more resources)", + "ocrHint": "Enable when your PDF is a scan or contains images.", + "refreshButton": "Re-sync document", + "startButton": "Send document to chat", + "status": { + "runningOcr": "Running OCR and extracting text…", + "extracting": "Extracting text from PDF…", + "syncing": "Syncing document with Stirling Bot…" + }, + "sessionSummary": "Context summary", + "contextDetails": "{{pages}} pages · {{chars}} characters synced", + "conversationTitle": "Conversation", + "emptyState": "Ask a question about your PDF to start the conversation.", + "escalationToggle": "Allow upgrade to GPT5-Mini for complex prompts", + "promptPlaceholder": "Ask anything about this PDF…", + "promptCounter": "{{used}} / {{limit}} characters", + "sendButton": "Send", + "viewerButton": "Chat about this PDF", + "userLabel": "You", + "botLabel": "Stirling Bot", + "confidence": "Confidence: {{value}}%", + "modelTag": "Model: {{name}}", + "toasts": { + "noFileTitle": "No PDF selected", + "noFileBody": "Please choose a document before starting the chatbot.", + "ackTitle": "Accept alpha notice", + "ackBody": "Please acknowledge the alpha warning before starting.", + "failedSessionTitle": "Could not prepare document", + "failedPromptTitle": "Unable to ask question", + "noSessionTitle": "Sync your document first", + "noSessionBody": "Send your PDF to the chatbot before asking questions." + }, + "noTextTitle": "No text detected in this PDF", + "noTextBody": "We could not find selectable text in this document. Would you like to run OCR to convert scanned pages into text?", + "noTextDismiss": "Maybe later", + "noTextRunOcr": "Run OCR and retry", + "toolNotice": "Chatbot lives inside the main workspace. Use the button below to focus the conversation pane on the left.", + "toolDescription": "Ask Stirling Bot questions about any uploaded PDF. The assistant uses your extracted text, so make sure the correct document is selected inside the chat panel.", + "toolOpenButton": "Open chat window", + "toolHint": "The chat window slides in from the left. If it is already open, this button simply focuses it and passes along the currently selected PDF.", + "toolTitleMenu": "Chatbot (Alpha)", + "toolMenuDescription": "Chat with Stirling Bot about the contents of your PDF." } } diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 3bf96f29f..360f8b5bc 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 ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -68,7 +69,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)} + /> + +