UI elements

This commit is contained in:
DarioGii 2025-11-07 17:48:14 +00:00 committed by Dario Ghunney Ware
parent f6ad398fb3
commit 1973c55d10
11 changed files with 438 additions and 136 deletions

View File

@ -626,6 +626,8 @@ public class ApplicationProperties {
private String primary = "gpt-5-nano"; private String primary = "gpt-5-nano";
private String fallback = "gpt-5-mini"; private String fallback = "gpt-5-mini";
private String embedding = "text-embedding-3-small"; private String embedding = "text-embedding-3-small";
private long connectTimeoutMillis = 10000;
private long readTimeoutMillis = 60000;
} }
@Data @Data

View File

@ -52,9 +52,9 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-cache' api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine' api 'com.github.ben-manes.caffeine:caffeine'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
implementation 'org.springframework.ai:spring-ai-starter-model-openai' api 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama' api 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-redis-store' api 'org.springframework.ai:spring-ai-redis-store'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0' implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17

View File

@ -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);
}
}

View File

@ -99,17 +99,16 @@ public class ChatbotController {
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) { private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
List<String> warnings = new ArrayList<>(); List<String> 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()) { 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."); warnings.add("Only extracted text is sent for analysis.");
if (session != null && session.isOcrRequested()) { if (session != null && session.isOcrRequested()) {
warnings.add("OCR was requested extra processing charges may apply."); warnings.add("OCR was requested extra processing charges may apply.");
} }
return warnings; return warnings;
} }
} }

View File

@ -3,6 +3,7 @@ package stirling.software.proprietary.controller;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import org.eclipse.jetty.client.HttpResponseException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -37,6 +38,14 @@ public class ChatbotExceptionHandler {
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
} }
@ExceptionHandler(HttpResponseException.class)
public ResponseEntity<Map<String, Object>> handleProvider(HttpResponseException ex) {
log.warn("Chatbot provider error", ex);
return buildResponse(
HttpStatus.BAD_GATEWAY,
"Chatbot provider rejected the request: " + ex.getMessage());
}
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) { private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
Map<String, Object> payload = Map<String, Object> payload =
Map.of( Map.of(

View File

@ -204,7 +204,19 @@ public class ChatbotConversationService {
List<ChatbotTextChunk> context, List<ChatbotTextChunk> context,
Map<String, String> metadata) { Map<String, String> metadata) {
Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, 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 = String content =
Optional.ofNullable(response) Optional.ofNullable(response)
.map(ChatResponse::getResults) .map(ChatResponse::getResults)
@ -298,4 +310,11 @@ public class ChatbotConversationService {
private record ModelReply( private record ModelReply(
String answer, double confidence, boolean requiresEscalation, String rationale) {} 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=***");
}
} }

View File

@ -138,7 +138,20 @@ public class ChatbotIngestionService {
if (chunkTexts.isEmpty()) { if (chunkTexts.isEmpty()) {
throw new ChatbotException("Unable to split document text into retrievable chunks"); 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()) { if (response.getResults().size() != chunkTexts.size()) {
throw new ChatbotException("Mismatch between chunks and embedding results"); throw new ChatbotException("Mismatch between chunks and embedding results");
} }
@ -165,4 +178,11 @@ public class ChatbotIngestionService {
chunks.size()); chunks.size());
return chunks; 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=***");
}
} }

View File

@ -5140,5 +5140,59 @@
"offline": "Backend Offline", "offline": "Backend Offline",
"starting": "Backend starting up...", "starting": "Backend starting up...",
"wait": "Please wait for the backend to finish launching and try again." "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"
} }
} }

View File

@ -1,12 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { import {
ActionIcon, ActionIcon,
Alert,
Badge, Badge,
Box, Box,
Button, Button,
Divider, Divider,
Drawer,
Group, Group,
Modal, Modal,
ScrollArea, ScrollArea,
@ -17,7 +15,7 @@ import {
Textarea, Textarea,
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded'; import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
@ -37,6 +35,7 @@ import {
} from '@app/services/chatbotService'; } from '@app/services/chatbotService';
import { useToast } from '@app/components/toast'; import { useToast } from '@app/components/toast';
import type { StirlingFile } from '@app/types/fileContext'; import type { StirlingFile } from '@app/types/fileContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
interface ChatMessage { interface ChatMessage {
id: string; id: string;
@ -61,14 +60,15 @@ const MAX_PROMPT_CHARS = 4000;
const ChatbotDrawer = () => { const ChatbotDrawer = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot(); const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot();
const { selectors } = useFileState(); const { selectors } = useFileState();
const { sidebarRefs } = useSidebarContext();
const { show } = useToast(); const { show } = useToast();
const files = selectors.getFiles(); const files = selectors.getFiles();
const [selectedFileId, setSelectedFileId] = useState<string | undefined>(); const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
const [alphaAccepted, setAlphaAccepted] = useState(false); const [alphaAccepted, setAlphaAccepted] = useState(false);
const [runOcr, setRunOcr] = useState(false); const [runOcr, setRunOcr] = useState(false);
const [allowEscalation, setAllowEscalation] = useState(true);
const [isStartingSession, setIsStartingSession] = useState(false); const [isStartingSession, setIsStartingSession] = useState(false);
const [isSendingMessage, setIsSendingMessage] = useState(false); const [isSendingMessage, setIsSendingMessage] = useState(false);
const [statusMessage, setStatusMessage] = useState<string>(''); const [statusMessage, setStatusMessage] = useState<string>('');
@ -80,6 +80,7 @@ const ChatbotDrawer = () => {
const [noTextModalOpen, setNoTextModalOpen] = useState(false); const [noTextModalOpen, setNoTextModalOpen] = useState(false);
const [pendingOcrRetry, setPendingOcrRetry] = useState(false); const [pendingOcrRetry, setPendingOcrRetry] = useState(false);
const scrollViewportRef = useRef<HTMLDivElement>(null); const scrollViewportRef = useRef<HTMLDivElement>(null);
const [panelAnchor, setPanelAnchor] = useState<{ right: number; top: number } | null>(null);
const selectedFile = useMemo<StirlingFile | undefined>( const selectedFile = useMemo<StirlingFile | undefined>(
() => files.find((file) => file.fileId === selectedFileId), () => files.find((file) => file.fileId === selectedFileId),
@ -128,6 +129,34 @@ const ChatbotDrawer = () => {
} }
}, [sessionInfo, selectedFileId]); }, [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) => { const handleAlphaAccept = (checked: boolean) => {
setAlphaAccepted(checked); setAlphaAccepted(checked);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -152,7 +181,7 @@ const ChatbotDrawer = () => {
if (!selectedFile) { if (!selectedFile) {
show({ show({
alertType: 'warning', 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.'), body: t('chatbot.toasts.noFileBody', 'Please choose a document before starting the chatbot.'),
}); });
return false; return false;
@ -264,7 +293,7 @@ const ChatbotDrawer = () => {
const reply = await sendChatbotPrompt({ const reply = await sendChatbotPrompt({
sessionId: sessionInfo.sessionId, sessionId: sessionInfo.sessionId,
prompt: trimmedPrompt, prompt: trimmedPrompt,
allowEscalation, allowEscalation: true,
}); });
setWarnings(reply.warnings ?? []); setWarnings(reply.warnings ?? []);
const assistant = convertAssistantMessage(reply); const assistant = convertAssistantMessage(reply);
@ -302,6 +331,22 @@ const ChatbotDrawer = () => {
); );
const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage; const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage;
const canSend = !disablePromptInput && prompt.trim().length > 0;
const handlePromptKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
if (canSend) {
event.preventDefault();
handleSendMessage();
}
}
};
const drawerTitle = ( const drawerTitle = (
<Group gap="xs"> <Group gap="xs">
@ -313,50 +358,148 @@ const ChatbotDrawer = () => {
const assistantWarnings = warnings.filter(Boolean); const assistantWarnings = warnings.filter(Boolean);
return ( const safeViewportWidth =
<Drawer viewportWidth || (typeof window !== 'undefined' ? window.innerWidth : 1280);
opened={isOpen} const safeViewportHeight =
onClose={closeChat} viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 900);
position="left" const desktopLeft = !isMobile ? (panelAnchor ? panelAnchor.right + 16 : 280) : undefined;
overlayProps={{ opacity: 0.65, blur: 3 }} const desktopBottom = !isMobile ? 24 : undefined;
size={isMobile ? '100%' : 420} const desktopWidth = !isMobile
title={drawerTitle} ? Math.min(440, Math.max(320, safeViewportWidth - (desktopLeft ?? 24) - 240))
withinPortal : undefined;
closeOnClickOutside={false} const desktopHeightPx = !isMobile
> ? Math.max(520, Math.min(safeViewportHeight - 48, Math.round(safeViewportHeight * 0.85)))
<Stack gap="md" h="100%"> : undefined;
<Alert color="yellow" icon={<WarningAmberRoundedIcon fontSize="small" />}
title={t('chatbot.alphaTitle', 'Experimental feature')} const renderMessageBubble = (message: ChatMessage) => {
const isUser = message.role === 'user';
const bubbleColor = isUser ? '#1f7ae0' : '#f3f4f6';
const textColor = isUser ? '#fff' : '#1f1f1f';
return (
<Box
key={message.id + message.role + message.createdAt.getTime()}
style={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
}}
>
<Box
p="sm"
maw="85%"
bg={bubbleColor}
style={{
borderRadius: 14,
borderTopRightRadius: isUser ? 4 : 14,
borderTopLeftRadius: isUser ? 14 : 4,
boxShadow: '0 2px 12px rgba(16,24,40,0.06)',
}}
> >
{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.')} <Group justify="space-between" mb={4} gap="xs">
</Alert> <Text size="xs" c={isUser ? 'rgba(255,255,255,0.8)' : 'dimmed'} tt="uppercase">
{isUser ? t('chatbot.userLabel', 'You') : t('chatbot.botLabel', 'Stirling Bot')}
</Text>
{!isUser && message.confidence !== undefined && (
<Badge
size="xs"
variant="light"
color={message.confidence >= 0.6 ? 'green' : 'yellow'}
>
{t('chatbot.confidence', 'Confidence: {{value}}%', {
value: Math.round(message.confidence * 100),
})}
</Badge>
)}
</Group>
<Text size="sm" c={textColor} style={{ whiteSpace: 'pre-wrap' }}>
{message.content}
</Text>
{!isUser && message.modelUsed && (
<Text size="xs" c="dimmed" mt={4}>
{t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })}
</Text>
)}
</Box>
</Box>
);
};
<Switch return (
checked={alphaAccepted} <>
label={t('chatbot.acceptAlphaLabel', 'I understand this feature is experimental and image content is not supported yet.')} <Modal
onChange={(event) => handleAlphaAccept(event.currentTarget.checked)} opened={isOpen}
/> onClose={closeChat}
withCloseButton
radius="lg"
overlayProps={{ opacity: 0.5, blur: 2 }}
fullScreen={isMobile}
centered={isMobile}
title={drawerTitle}
styles={{
content: {
width: isMobile ? '100%' : desktopWidth,
left: isMobile ? undefined : desktopLeft,
right: isMobile ? 0 : undefined,
margin: isMobile ? undefined : 0,
top: isMobile ? undefined : undefined,
bottom: isMobile ? 0 : desktopBottom,
position: isMobile ? undefined : 'fixed',
height: isMobile ? '100%' : desktopHeightPx ? `${desktopHeightPx}px` : '75vh',
overflow: 'hidden',
},
body: {
paddingTop: 'var(--mantine-spacing-md)',
paddingBottom: 'var(--mantine-spacing-md)',
height: '100%',
display: 'flex',
flexDirection: 'column',
},
}}
transitionProps={{ transition: 'slide-left', duration: 200 }}
>
<Stack gap="sm" h="100%" style={{ minHeight: 0 }}>
<Box
p="sm"
style={{
border: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-subtle)',
display: 'flex',
gap: '0.5rem',
alignItems: 'flex-start',
}}
>
<WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
<Box>
<Text fw={600}>{t('chatbot.alphaTitle', 'Experimental feature')}</Text>
<Text size="sm">
{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.')}
</Text>
</Box>
</Box>
<Select <Group align="flex-end" justify="space-between" gap="md" wrap="wrap">
label={t('chatbot.fileLabel', 'Document to query')} <Select
placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')} label={t('chatbot.fileLabel', 'Document')}
data={fileOptions} placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')}
value={selectedFileId} data={fileOptions}
onChange={(value) => setSelectedFileId(value || undefined)} value={selectedFileId}
nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')} onChange={(value) => setSelectedFileId(value || undefined)}
/> nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')}
style={{ flex: '1 1 200px' }}
<Group justify="space-between" align="center">
<Switch
checked={runOcr}
onChange={(event) => setRunOcr(event.currentTarget.checked)}
label={t('chatbot.ocrToggle', 'Run OCR before extracting text (uses more resources)')}
/> />
<Tooltip label={t('chatbot.ocrHint', 'Enable when your PDF is a scan or contains images.')}> <Stack gap={4} style={{ minWidth: 160 }}>
<ActionIcon variant="subtle" aria-label={t('chatbot.ocrHint', 'OCR hint')}> <Switch
<SmartToyRoundedIcon fontSize="small" /> checked={alphaAccepted}
</ActionIcon> label={t('chatbot.acceptAlphaLabel', 'Alpha notice acknowledged')}
</Tooltip> onChange={(event) => handleAlphaAccept(event.currentTarget.checked)}
/>
<Switch
checked={runOcr}
onChange={(event) => setRunOcr(event.currentTarget.checked)}
label={t('chatbot.ocrToggle', 'Run OCR before extracting text')}
/>
</Stack>
</Group> </Group>
<Button <Button
@ -373,7 +516,16 @@ const ChatbotDrawer = () => {
</Button> </Button>
{statusMessage && ( {statusMessage && (
<Alert color="blue">{statusMessage}</Alert> <Box
p="sm"
style={{
border: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-muted)',
}}
>
<Text size="sm" c="blue">{statusMessage}</Text>
</Box>
)} )}
{sessionInfo && contextStats && ( {sessionInfo && contextStats && (
@ -388,70 +540,55 @@ const ChatbotDrawer = () => {
</Box> </Box>
)} )}
{assistantWarnings.length > 0 && (
<Alert color="yellow">
<Stack gap={4}>
{assistantWarnings.map((warning) => (
<Text key={warning} size="sm">{warning}</Text>
))}
</Stack>
</Alert>
)}
<Divider label={t('chatbot.conversationTitle', 'Conversation')} /> <Divider label={t('chatbot.conversationTitle', 'Conversation')} />
<ScrollArea viewportRef={scrollViewportRef} style={{ flex: 1 }}> <Box style={{ flex: 1, minHeight: 0 }}>
<Stack gap="sm" pr="sm"> <ScrollArea viewportRef={scrollViewportRef} style={{ height: '100%' }}>
{messages.length === 0 && ( <Stack gap="sm" pr="xs">
<Text size="sm" c="dimmed"> {assistantWarnings.length > 0 &&
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')} assistantWarnings.map((warning) => (
</Text> <Box
)} key={warning}
{messages.map((message) => ( p="sm"
<Box bg="var(--bg-muted)"
key={message.id + message.role + message.createdAt.getTime()} style={{ borderRadius: 12, border: '1px dashed var(--border-subtle)' }}
p="sm" >
bg={message.role === 'user' ? 'var(--bg-toolbar)' : 'var(--bg-panel)'} <Group gap="xs" align="flex-start">
style={{ borderRadius: 8 }} <WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
> <Text size="sm">{warning}</Text>
<Group justify="space-between" mb={4} wrap="nowrap"> </Group>
<Text size="xs" c="dimmed" tt="uppercase"> </Box>
{message.role === 'user' ))}
? t('chatbot.userLabel', 'You') {messages.length === 0 && (
: t('chatbot.botLabel', 'Stirling Bot')} <Text size="sm" c="dimmed">
</Text> {t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
{message.role === 'assistant' && message.confidence !== undefined && ( </Text>
<Badge size="xs" variant="light" color={message.confidence >= 0.6 ? 'green' : 'yellow'}> )}
{t('chatbot.confidence', 'Confidence: {{value}}%', { {messages.map(renderMessageBubble)}
value: Math.round(message.confidence * 100), </Stack>
})} </ScrollArea>
</Badge> </Box>
)}
</Group>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{message.content}</Text>
{message.role === 'assistant' && message.modelUsed && (
<Text size="xs" c="dimmed" mt={4}>
{t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })}
</Text>
)}
</Box>
))}
</Stack>
</ScrollArea>
<Stack gap="xs"> <Stack
<Switch gap="xs"
checked={allowEscalation} style={{
onChange={(event) => setAllowEscalation(event.currentTarget.checked)} flexShrink: 0,
label={t('chatbot.escalationToggle', 'Allow upgrade to GPT5-Mini for complex prompts')} border: '1px solid var(--border-subtle)',
/> borderRadius: 12,
padding: '0.75rem',
background: 'var(--bg-toolbar)',
}}
>
<Textarea <Textarea
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')} placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
minRows={3} minRows={2}
autosize
maxRows={6}
value={prompt} value={prompt}
maxLength={MAX_PROMPT_CHARS} maxLength={MAX_PROMPT_CHARS}
onChange={(event) => setPrompt(event.currentTarget.value)} onChange={(event) => setPrompt(event.currentTarget.value)}
disabled={disablePromptInput} disabled={disablePromptInput}
onKeyDown={handlePromptKeyDown}
/> />
<Group justify="space-between"> <Group justify="space-between">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@ -464,13 +601,14 @@ const ChatbotDrawer = () => {
rightSection={<SendRoundedIcon fontSize="small" />} rightSection={<SendRoundedIcon fontSize="small" />}
onClick={handleSendMessage} onClick={handleSendMessage}
loading={isSendingMessage} loading={isSendingMessage}
disabled={disablePromptInput || prompt.trim().length === 0} disabled={!canSend}
> >
{t('chatbot.sendButton', 'Send')} {t('chatbot.sendButton', 'Send')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Stack> </Stack>
</Modal>
<Modal <Modal
opened={noTextModalOpen} opened={noTextModalOpen}
@ -501,7 +639,7 @@ const ChatbotDrawer = () => {
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
</Drawer> </>
); );
}; };

View File

@ -16,6 +16,7 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useChatbot } from '@app/contexts/ChatbotContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
@ -41,6 +42,7 @@ export default function RightRail() {
const viewerContext = React.useContext(ViewerContext); const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext(); const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail(); const { buttons, actions, allButtonsDisabled } = useRightRail();
const { openChat } = useChatbot();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
@ -55,6 +57,8 @@ export default function RightRail() {
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const exportState = viewerContext?.getExportState?.(); const exportState = viewerContext?.getExportState?.();
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
const viewerActiveFile = activeFiles[viewerContext?.activeFileIndex ?? 0];
const totalItems = useMemo(() => { const totalItems = useMemo(() => {
if (currentView === 'pageEditor') return pageEditorTotalPages; if (currentView === 'pageEditor') return pageEditorTotalPages;
@ -222,6 +226,24 @@ export default function RightRail() {
</ActionIcon>, </ActionIcon>,
downloadTooltip downloadTooltip
)} )}
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
if (viewerActiveFile) {
openChat({ source: 'viewer', fileId: viewerActiveFile.fileId });
} else {
openChat({ source: 'viewer' });
}
}}
disabled={!viewerActiveFile}
>
<LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>,
chatLabel
)}
</div> </div>
<div className="right-rail-spacer" /> <div className="right-rail-spacer" />

View File

@ -7,17 +7,14 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip'; import { Tooltip } from '@app/components/shared/Tooltip';
import { SearchInterface } from '@app/components/viewer/SearchInterface'; import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls'; import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useChatbot } from '@app/contexts/ChatbotContext';
import { useFileState } from '@app/contexts/FileContext'; import { useFileState } from '@app/contexts/FileContext';
export function useViewerRightRailButtons() { export function useViewerRightRailButtons() {
const { t } = useTranslation(); const { t } = useTranslation();
const viewer = useViewer(); const viewer = useViewer();
const { openChat } = useChatbot();
const { selectors } = useFileState(); const { selectors } = useFileState();
const filesSignature = selectors.getFilesSignature(); const filesSignature = selectors.getFilesSignature();
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]); const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
const activeFile = files[viewer.activeFileIndex] || files[0];
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false); const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
// Lift i18n labels out of memo for clarity // Lift i18n labels out of memo for clarity
@ -26,7 +23,6 @@ export function useViewerRightRailButtons() {
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left'); const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right'); const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => { const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [ return [
@ -61,20 +57,6 @@ export function useViewerRightRailButtons() {
</Tooltip> </Tooltip>
) )
}, },
{
id: 'viewer-chatbot',
icon: <LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />,
tooltip: chatLabel,
ariaLabel: chatLabel,
section: 'top' as const,
order: 15,
disabled: !activeFile,
onClick: () => {
if (activeFile) {
openChat({ source: 'viewer', fileId: activeFile.fileId });
}
},
},
{ {
id: 'viewer-pan-mode', id: 'viewer-pan-mode',
tooltip: panLabel, tooltip: panLabel,
@ -150,9 +132,6 @@ export function useViewerRightRailButtons() {
rotateLeftLabel, rotateLeftLabel,
rotateRightLabel, rotateRightLabel,
sidebarLabel, sidebarLabel,
chatLabel,
openChat,
activeFile?.fileId,
]); ]);
useRightRailButtons(viewerButtons); useRightRailButtons(viewerButtons);