diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 9af346155..7b3c82c75 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -626,6 +626,8 @@ public class ApplicationProperties { private String primary = "gpt-5-nano"; private String fallback = "gpt-5-mini"; private String embedding = "text-embedding-3-small"; + private long connectTimeoutMillis = 10000; + private long readTimeoutMillis = 60000; } @Data diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 43af56786..048c5209b 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -52,9 +52,9 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-cache' api 'com.github.ben-manes.caffeine:caffeine' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38' - implementation 'org.springframework.ai:spring-ai-starter-model-openai' - implementation 'org.springframework.ai:spring-ai-starter-model-ollama' - implementation 'org.springframework.ai:spring-ai-redis-store' + api 'org.springframework.ai:spring-ai-starter-model-openai' + api 'org.springframework.ai:spring-ai-starter-model-ollama' + api 'org.springframework.ai:spring-ai-redis-store' implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ChatbotAiClientConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ChatbotAiClientConfiguration.java new file mode 100644 index 000000000..f803b7a3e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ChatbotAiClientConfiguration.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.configuration; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Premium; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures; +import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot; + +@Configuration +@ConditionalOnClass(RestClientCustomizer.class) +@ConditionalOnProperty(value = "spring.ai.openai.enabled", havingValue = "true") +public class ChatbotAiClientConfiguration { + + @Bean + public RestClientCustomizer chatbotRestClientCustomizer( + ApplicationProperties applicationProperties) { + long connectTimeout = resolveConnectTimeout(applicationProperties); + long readTimeout = resolveReadTimeout(applicationProperties); + return builder -> builder.requestFactory(createRequestFactory(connectTimeout, readTimeout)); + } + + private JdkClientHttpRequestFactory createRequestFactory( + long connectTimeoutMillis, long readTimeoutMillis) { + HttpClient httpClient = + HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(connectTimeoutMillis)) + .build(); + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient); + factory.setReadTimeout((int) readTimeoutMillis); + return factory; + } + + private long resolveConnectTimeout(ApplicationProperties properties) { + long configured = resolveChatbot(properties).getModels().getConnectTimeoutMillis(); + return configured > 0 ? configured : 30000L; + } + + private long resolveReadTimeout(ApplicationProperties properties) { + long configured = resolveChatbot(properties).getModels().getReadTimeoutMillis(); + return configured > 0 ? configured : 120000L; + } + + private Chatbot resolveChatbot(ApplicationProperties properties) { + return Optional.ofNullable(properties) + .map(ApplicationProperties::getPremium) + .map(Premium::getProFeatures) + .map(ProFeatures::getChatbot) + .orElseGet(Chatbot::new); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java index 052941828..c00be315f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotController.java @@ -99,17 +99,16 @@ public class ChatbotController { private List sessionWarnings(ChatbotSettings settings, ChatbotSession session) { List warnings = new ArrayList<>(); - if (settings.alphaWarning()) { - warnings.add("Chatbot feature is in alpha and may change."); - } - warnings.add("Image-based content is not supported yet."); + if (session != null && session.isImageContentDetected()) { - warnings.add("Detected images will be ignored until image support ships."); + warnings.add("Images detected - Images are not currently supported."); } + warnings.add("Only extracted text is sent for analysis."); if (session != null && session.isOcrRequested()) { warnings.add("OCR was requested – extra processing charges may apply."); } + return warnings; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java index ae8403d0e..293513ff7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/ChatbotExceptionHandler.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.controller; import java.time.Instant; import java.util.Map; +import org.eclipse.jetty.client.HttpResponseException; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -37,6 +38,14 @@ public class ChatbotExceptionHandler { return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + @ExceptionHandler(HttpResponseException.class) + public ResponseEntity> handleProvider(HttpResponseException ex) { + log.warn("Chatbot provider error", ex); + return buildResponse( + HttpStatus.BAD_GATEWAY, + "Chatbot provider rejected the request: " + ex.getMessage()); + } + private ResponseEntity> buildResponse(HttpStatus status, String message) { Map payload = Map.of( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java index 6730b3ec0..9f067cdad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotConversationService.java @@ -204,7 +204,19 @@ public class ChatbotConversationService { List context, Map metadata) { Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata); - ChatResponse response = chatModel.call(requestPrompt); + ChatResponse response; + try { + response = chatModel.call(requestPrompt); + } catch (org.eclipse.jetty.client.HttpResponseException ex) { + throw new ChatbotException( + "Chat model rejected the request: " + sanitizeRemoteMessage(ex.getMessage()), + ex); + } catch (RuntimeException ex) { + throw new ChatbotException( + "Failed to contact chat model provider: " + + sanitizeRemoteMessage(ex.getMessage()), + ex); + } String content = Optional.ofNullable(response) .map(ChatResponse::getResults) @@ -298,4 +310,11 @@ public class ChatbotConversationService { private record ModelReply( String answer, double confidence, boolean requiresEscalation, String rationale) {} + + private String sanitizeRemoteMessage(String message) { + if (!StringUtils.hasText(message)) { + return "unexpected provider error"; + } + return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java index 8886368c1..300ca9a54 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/chatbot/ChatbotIngestionService.java @@ -138,7 +138,20 @@ public class ChatbotIngestionService { if (chunkTexts.isEmpty()) { throw new ChatbotException("Unable to split document text into retrievable chunks"); } - EmbeddingResponse response = embeddingModel.embedForResponse(chunkTexts); + EmbeddingResponse response; + try { + response = embeddingModel.embedForResponse(chunkTexts); + } catch (org.eclipse.jetty.client.HttpResponseException ex) { + throw new ChatbotException( + "Embedding provider rejected the request: " + + sanitizeRemoteMessage(ex.getMessage()), + ex); + } catch (RuntimeException ex) { + throw new ChatbotException( + "Failed to compute embeddings for chatbot ingestion: " + + sanitizeRemoteMessage(ex.getMessage()), + ex); + } if (response.getResults().size() != chunkTexts.size()) { throw new ChatbotException("Mismatch between chunks and embedding results"); } @@ -165,4 +178,11 @@ public class ChatbotIngestionService { chunks.size()); return chunks; } + + private String sanitizeRemoteMessage(String message) { + if (!StringUtils.hasText(message)) { + return "unexpected provider error"; + } + return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***"); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 81e2f0c64..1e37c3513 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5140,5 +5140,59 @@ "offline": "Backend Offline", "starting": "Backend starting up...", "wait": "Please wait for the backend to finish launching and try again." + }, + "chatbot": { + "title": "Stirling PDF Bot", + "alphaBadge": "Alpha", + "alphaTitle": "Experimental feature", + "alphaDescription": "This Chatbot feature is in currently in alpha and is subject to change. Image-based content is not supported yet. Responses may be imperfect, so double-check important answers.", + "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", + "sessionSummary": "Context summary", + "contextDetails": "{{pages}} pages · {{chars}} characters synced", + "conversationTitle": "Conversation", + "emptyState": "Ask a question about your PDF to start the conversation.", + "userLabel": "You", + "botLabel": "Stirling Bot", + "confidence": "Confidence: {{value}}%", + "modelTag": "Model: {{name}}", + "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", + "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.", + "status": { + "runningOcr": "Running OCR and extracting text…", + "extracting": "Extracting text from PDF…", + "syncing": "Syncing document with Stirling Bot…" + }, + "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", + "failedSessionBody": "We were unable to sync this PDF with the chatbot. Please try again.", + "failedPromptTitle": "Unable to ask question", + "failedPromptBody": "The chatbot could not process your question. Wait a moment and try again.", + "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" } } diff --git a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx index 4610a3d64..16e97d44b 100644 --- a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx +++ b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx @@ -1,12 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { ActionIcon, - Alert, Badge, Box, Button, Divider, - Drawer, Group, Modal, ScrollArea, @@ -17,7 +15,7 @@ import { Textarea, Tooltip, } from '@mantine/core'; -import { useMediaQuery } from '@mantine/hooks'; +import { useMediaQuery, useViewportSize } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; @@ -37,6 +35,7 @@ import { } from '@app/services/chatbotService'; import { useToast } from '@app/components/toast'; import type { StirlingFile } from '@app/types/fileContext'; +import { useSidebarContext } from '@app/contexts/SidebarContext'; interface ChatMessage { id: string; @@ -61,14 +60,15 @@ const MAX_PROMPT_CHARS = 4000; const ChatbotDrawer = () => { const { t } = useTranslation(); const isMobile = useMediaQuery('(max-width: 768px)'); + const { width: viewportWidth, height: viewportHeight } = useViewportSize(); const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot(); const { selectors } = useFileState(); + const { sidebarRefs } = useSidebarContext(); 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(''); @@ -80,6 +80,7 @@ const ChatbotDrawer = () => { const [noTextModalOpen, setNoTextModalOpen] = useState(false); const [pendingOcrRetry, setPendingOcrRetry] = useState(false); const scrollViewportRef = useRef(null); + const [panelAnchor, setPanelAnchor] = useState<{ right: number; top: number } | null>(null); const selectedFile = useMemo( () => files.find((file) => file.fileId === selectedFileId), @@ -128,6 +129,34 @@ const ChatbotDrawer = () => { } }, [sessionInfo, selectedFileId]); + useLayoutEffect(() => { + if (isMobile || !isOpen) { + setPanelAnchor(null); + return; + } + const panelEl = sidebarRefs.toolPanelRef.current; + if (!panelEl) { + setPanelAnchor(null); + return; + } + const updateAnchor = () => { + const rect = panelEl.getBoundingClientRect(); + setPanelAnchor({ + right: rect.right, + top: rect.top, + }); + }; + updateAnchor(); + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateAnchor()) : null; + observer?.observe(panelEl); + const handleResize = () => updateAnchor(); + window.addEventListener('resize', handleResize); + return () => { + observer?.disconnect(); + window.removeEventListener('resize', handleResize); + }; + }, [isMobile, isOpen, sidebarRefs.toolPanelRef]); + const handleAlphaAccept = (checked: boolean) => { setAlphaAccepted(checked); if (typeof window !== 'undefined') { @@ -152,7 +181,7 @@ const ChatbotDrawer = () => { if (!selectedFile) { show({ alertType: 'warning', - title: t('chatbot.toasts.noFileTitle', 'No PDF selected'), + title: t('chatbot.toasts.noFileTitle', 'No PDF selected'), body: t('chatbot.toasts.noFileBody', 'Please choose a document before starting the chatbot.'), }); return false; @@ -264,7 +293,7 @@ const ChatbotDrawer = () => { const reply = await sendChatbotPrompt({ sessionId: sessionInfo.sessionId, prompt: trimmedPrompt, - allowEscalation, + allowEscalation: true, }); setWarnings(reply.warnings ?? []); const assistant = convertAssistantMessage(reply); @@ -302,6 +331,22 @@ const ChatbotDrawer = () => { ); const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage; + const canSend = !disablePromptInput && prompt.trim().length > 0; + + const handlePromptKeyDown = (event: KeyboardEvent) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + if (canSend) { + event.preventDefault(); + handleSendMessage(); + } + } + }; const drawerTitle = ( @@ -313,50 +358,148 @@ const ChatbotDrawer = () => { const assistantWarnings = warnings.filter(Boolean); - return ( - - - } - title={t('chatbot.alphaTitle', 'Experimental feature')} + const safeViewportWidth = + viewportWidth || (typeof window !== 'undefined' ? window.innerWidth : 1280); + const safeViewportHeight = + viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 900); + const desktopLeft = !isMobile ? (panelAnchor ? panelAnchor.right + 16 : 280) : undefined; + const desktopBottom = !isMobile ? 24 : undefined; + const desktopWidth = !isMobile + ? Math.min(440, Math.max(320, safeViewportWidth - (desktopLeft ?? 24) - 240)) + : undefined; + const desktopHeightPx = !isMobile + ? Math.max(520, Math.min(safeViewportHeight - 48, Math.round(safeViewportHeight * 0.85))) + : undefined; + + const renderMessageBubble = (message: ChatMessage) => { + const isUser = message.role === 'user'; + const bubbleColor = isUser ? '#1f7ae0' : '#f3f4f6'; + const textColor = isUser ? '#fff' : '#1f1f1f'; + + return ( + + - {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.')} - + + + {isUser ? t('chatbot.userLabel', 'You') : t('chatbot.botLabel', 'Stirling Bot')} + + {!isUser && message.confidence !== undefined && ( + = 0.6 ? 'green' : 'yellow'} + > + {t('chatbot.confidence', 'Confidence: {{value}}%', { + value: Math.round(message.confidence * 100), + })} + + )} + + + {message.content} + + {!isUser && message.modelUsed && ( + + {t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })} + + )} + + + ); + }; - handleAlphaAccept(event.currentTarget.checked)} - /> + return ( + <> + + + + + + {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.')} + + + - setSelectedFileId(value || undefined)} + nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')} + style={{ flex: '1 1 200px' }} /> - - - - - + + handleAlphaAccept(event.currentTarget.checked)} + /> + setRunOcr(event.currentTarget.checked)} + label={t('chatbot.ocrToggle', 'Run OCR before extracting text')} + /> + {statusMessage && ( - {statusMessage} + + {statusMessage} + )} {sessionInfo && contextStats && ( @@ -388,70 +540,55 @@ const ChatbotDrawer = () => { )} - {assistantWarnings.length > 0 && ( - - - {assistantWarnings.map((warning) => ( - {warning} - ))} - - - )} - - - - {messages.length === 0 && ( - - {t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')} - - )} - {messages.map((message) => ( - - - - {message.role === 'user' - ? t('chatbot.userLabel', 'You') - : t('chatbot.botLabel', 'Stirling Bot')} - - {message.role === 'assistant' && message.confidence !== undefined && ( - = 0.6 ? 'green' : 'yellow'}> - {t('chatbot.confidence', 'Confidence: {{value}}%', { - value: Math.round(message.confidence * 100), - })} - - )} - - {message.content} - {message.role === 'assistant' && message.modelUsed && ( - - {t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })} - - )} - - ))} - - + + + + {assistantWarnings.length > 0 && + assistantWarnings.map((warning) => ( + + + + {warning} + + + ))} + {messages.length === 0 && ( + + {t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')} + + )} + {messages.map(renderMessageBubble)} + + + - - setAllowEscalation(event.currentTarget.checked)} - label={t('chatbot.escalationToggle', 'Allow upgrade to GPT5-Mini for complex prompts')} - /> +