mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
chatbot UI
This commit is contained in:
parent
7b9f83b08f
commit
a8d105430f
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
<ChatbotProvider>
|
||||
{children}
|
||||
</ChatbotProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
|
||||
508
frontend/src/core/components/chatbot/ChatbotDrawer.tsx
Normal file
508
frontend/src/core/components/chatbot/ChatbotDrawer.tsx
Normal file
@ -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<string | undefined>();
|
||||
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<string>('');
|
||||
const [sessionInfo, setSessionInfo] = useState<ChatbotSessionInfo | null>(null);
|
||||
const [contextStats, setContextStats] = useState<{ pageCount: number; characterCount: number } | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [noTextModalOpen, setNoTextModalOpen] = useState(false);
|
||||
const [pendingOcrRetry, setPendingOcrRetry] = useState(false);
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedFile = useMemo<StirlingFile | undefined>(
|
||||
() => 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 <T,>(label: string, fn: () => Promise<T>): Promise<T> => {
|
||||
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 = (
|
||||
<Group gap="xs">
|
||||
<SmartToyRoundedIcon fontSize="small" />
|
||||
<Text fw={600}>{t('chatbot.title', 'Stirling PDF Bot')}</Text>
|
||||
<Badge color="yellow" size="sm">{t('chatbot.alphaBadge', 'Alpha')}</Badge>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const assistantWarnings = warnings.filter(Boolean);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={isOpen}
|
||||
onClose={closeChat}
|
||||
position="left"
|
||||
overlayProps={{ opacity: 0.65, blur: 3 }}
|
||||
size={isMobile ? '100%' : 420}
|
||||
title={drawerTitle}
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Stack gap="md" h="100%">
|
||||
<Alert color="yellow" icon={<WarningAmberRoundedIcon fontSize="small" />}
|
||||
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.')}
|
||||
</Alert>
|
||||
|
||||
<Switch
|
||||
checked={alphaAccepted}
|
||||
label={t('chatbot.acceptAlphaLabel', 'I understand this feature is experimental and image content is not supported yet.')}
|
||||
onChange={(event) => handleAlphaAccept(event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('chatbot.fileLabel', 'Document to query')}
|
||||
placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')}
|
||||
data={fileOptions}
|
||||
value={selectedFileId}
|
||||
onChange={(value) => setSelectedFileId(value || undefined)}
|
||||
nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')}
|
||||
/>
|
||||
|
||||
<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.')}>
|
||||
<ActionIcon variant="subtle" aria-label={t('chatbot.ocrHint', 'OCR hint')}>
|
||||
<SmartToyRoundedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="filled"
|
||||
leftSection={<RefreshRoundedIcon fontSize="small" />}
|
||||
loading={isStartingSession}
|
||||
onClick={() => handleSessionStart()}
|
||||
disabled={!selectedFile || !alphaAccepted}
|
||||
>
|
||||
{sessionInfo
|
||||
? t('chatbot.refreshButton', 'Re-sync document')
|
||||
: t('chatbot.startButton', 'Send document to chat')}
|
||||
</Button>
|
||||
|
||||
{statusMessage && (
|
||||
<Alert color="blue">{statusMessage}</Alert>
|
||||
)}
|
||||
|
||||
{sessionInfo && contextStats && (
|
||||
<Box>
|
||||
<Text fw={600}>{t('chatbot.sessionSummary', 'Context summary')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('chatbot.contextDetails', '{{pages}} pages · {{chars}} characters synced', {
|
||||
pages: contextStats.pageCount,
|
||||
chars: contextStats.characterCount.toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
</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')} />
|
||||
|
||||
<ScrollArea viewportRef={scrollViewportRef} style={{ flex: 1 }}>
|
||||
<Stack gap="sm" pr="sm">
|
||||
{messages.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<Box
|
||||
key={message.id + message.role + message.createdAt.getTime()}
|
||||
p="sm"
|
||||
bg={message.role === 'user' ? 'var(--bg-toolbar)' : 'var(--bg-panel)'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
{message.role === 'user'
|
||||
? t('chatbot.userLabel', 'You')
|
||||
: t('chatbot.botLabel', 'Stirling Bot')}
|
||||
</Text>
|
||||
{message.role === 'assistant' && 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" 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">
|
||||
<Switch
|
||||
checked={allowEscalation}
|
||||
onChange={(event) => setAllowEscalation(event.currentTarget.checked)}
|
||||
label={t('chatbot.escalationToggle', 'Allow upgrade to GPT5-Mini for complex prompts')}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
|
||||
minRows={3}
|
||||
value={prompt}
|
||||
maxLength={MAX_PROMPT_CHARS}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
disabled={disablePromptInput}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('chatbot.promptCounter', '{{used}} / {{limit}} characters', {
|
||||
used: prompt.length,
|
||||
limit: MAX_PROMPT_CHARS,
|
||||
})}
|
||||
</Text>
|
||||
<Button
|
||||
rightSection={<SendRoundedIcon fontSize="small" />}
|
||||
onClick={handleSendMessage}
|
||||
loading={isSendingMessage}
|
||||
disabled={disablePromptInput || prompt.trim().length === 0}
|
||||
>
|
||||
{t('chatbot.sendButton', 'Send')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={noTextModalOpen}
|
||||
onClose={() => setNoTextModalOpen(false)}
|
||||
title={t('chatbot.noTextTitle', 'No text detected in this PDF')}
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t('chatbot.noTextBody', 'We could not find selectable text in this document. Would you like to run OCR to convert scanned pages into text?')}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" leftSection={<CloseRoundedIcon fontSize="small" />} onClick={() => setNoTextModalOpen(false)}>
|
||||
{t('chatbot.noTextDismiss', 'Maybe later')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<SmartToyRoundedIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
setNoTextModalOpen(false);
|
||||
setRunOcr(true);
|
||||
if (pendingOcrRetry) {
|
||||
handleSessionStart(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('chatbot.noTextRunOcr', 'Run OCR and retry')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotDrawer;
|
||||
@ -7,10 +7,17 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||
import { useChatbot } from '@app/contexts/ChatbotContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const { openChat } = useChatbot();
|
||||
const { selectors } = useFileState();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
|
||||
const activeFile = files[viewer.activeFileIndex] || files[0];
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
@ -19,6 +26,7 @@ export function useViewerRightRailButtons() {
|
||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
@ -53,6 +61,20 @@ export function useViewerRightRailButtons() {
|
||||
</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',
|
||||
tooltip: panLabel,
|
||||
@ -119,7 +141,19 @@ export function useViewerRightRailButtons() {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
|
||||
}, [
|
||||
t,
|
||||
viewer,
|
||||
isPanning,
|
||||
searchLabel,
|
||||
panLabel,
|
||||
rotateLeftLabel,
|
||||
rotateRightLabel,
|
||||
sidebarLabel,
|
||||
chatLabel,
|
||||
openChat,
|
||||
activeFile?.fileId,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
61
frontend/src/core/contexts/ChatbotContext.tsx
Normal file
61
frontend/src/core/contexts/ChatbotContext.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
type ChatbotSource = 'viewer' | 'tool';
|
||||
|
||||
interface OpenChatOptions {
|
||||
source?: ChatbotSource;
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
isOpen: boolean;
|
||||
source: ChatbotSource;
|
||||
preferredFileId?: string;
|
||||
openChat: (options?: OpenChatOptions) => void;
|
||||
closeChat: () => void;
|
||||
setPreferredFileId: (fileId?: string) => void;
|
||||
}
|
||||
|
||||
const ChatbotContext = createContext<ChatbotContextValue | undefined>(undefined);
|
||||
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [source, setSource] = useState<ChatbotSource>('viewer');
|
||||
const [preferredFileId, setPreferredFileId] = useState<string | undefined>();
|
||||
|
||||
const openChat = useCallback((options: OpenChatOptions = {}) => {
|
||||
if (options.source) {
|
||||
setSource(options.source);
|
||||
}
|
||||
if (options.fileId) {
|
||||
setPreferredFileId(options.fileId);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeChat = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
source,
|
||||
preferredFileId,
|
||||
openChat,
|
||||
closeChat,
|
||||
setPreferredFileId,
|
||||
}),
|
||||
[isOpen, source, preferredFileId, openChat, closeChat]
|
||||
);
|
||||
|
||||
return <ChatbotContext.Provider value={value}>{children}</ChatbotContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChatbot() {
|
||||
const context = useContext(ChatbotContext);
|
||||
if (!context) {
|
||||
throw new Error('useChatbot must be used within a ChatbotProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -5,6 +5,7 @@ import SplitPdfPanel from "@app/tools/Split";
|
||||
import CompressPdfPanel from "@app/tools/Compress";
|
||||
import OCRPanel from "@app/tools/OCR";
|
||||
import ConvertPanel from "@app/tools/Convert";
|
||||
import ChatbotAssistant from "@app/tools/ChatbotAssistant";
|
||||
import Sanitize from "@app/tools/Sanitize";
|
||||
import AddPassword from "@app/tools/AddPassword";
|
||||
import ChangePermissions from "@app/tools/ChangePermissions";
|
||||
@ -154,6 +155,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
chatbot: {
|
||||
icon: <LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t('chatbot.toolTitleMenu', 'Chatbot (Alpha)'),
|
||||
component: ChatbotAssistant,
|
||||
description: t('chatbot.toolMenuDescription', 'Chat with Stirling Bot about the contents of your PDF.'),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
maxFiles: 1,
|
||||
automationSettings: null,
|
||||
supportsAutomate: false,
|
||||
synonyms: getSynonyms(t, 'chatbot'),
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
|
||||
@ -20,6 +20,7 @@ import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
||||
import ChatbotDrawer from "@app/components/chatbot/ChatbotDrawer";
|
||||
|
||||
import "@app/pages/HomePage.css";
|
||||
|
||||
@ -280,6 +281,7 @@ export default function HomePage() {
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
)}
|
||||
<ChatbotDrawer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
65
frontend/src/core/services/chatbotOcrService.ts
Normal file
65
frontend/src/core/services/chatbotOcrService.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
en: 'eng',
|
||||
fr: 'fra',
|
||||
de: 'deu',
|
||||
es: 'spa',
|
||||
it: 'ita',
|
||||
pt: 'por',
|
||||
nl: 'nld',
|
||||
sv: 'swe',
|
||||
fi: 'fin',
|
||||
da: 'dan',
|
||||
no: 'nor',
|
||||
cs: 'ces',
|
||||
pl: 'pol',
|
||||
ru: 'rus',
|
||||
ja: 'jpn',
|
||||
ko: 'kor',
|
||||
zh: 'chi_sim',
|
||||
};
|
||||
|
||||
function detectOcrLanguage(): string {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'eng';
|
||||
}
|
||||
const locale = navigator.language?.toLowerCase() ?? 'en';
|
||||
const short = locale.split('-')[0];
|
||||
return LANGUAGE_MAP[short] || 'eng';
|
||||
}
|
||||
|
||||
export async function runOcrForChat(file: File): Promise<File> {
|
||||
const language = detectOcrLanguage();
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file, file.name);
|
||||
formData.append('languages', language);
|
||||
formData.append('ocrType', 'skip-text');
|
||||
formData.append('ocrRenderType', 'sandwich');
|
||||
formData.append('sidecar', 'false');
|
||||
formData.append('deskew', 'false');
|
||||
formData.append('clean', 'false');
|
||||
formData.append('cleanFinal', 'false');
|
||||
formData.append('removeImagesAfter', 'false');
|
||||
|
||||
const response = await apiClient.post<Blob>(
|
||||
'/api/v1/misc/ocr-pdf',
|
||||
formData,
|
||||
{
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const blob = response.data;
|
||||
const head = await blob.slice(0, 5).text().catch(() => '');
|
||||
if (!head.startsWith('%PDF')) {
|
||||
throw new Error('OCR service did not return a valid PDF response.');
|
||||
}
|
||||
|
||||
const safeName = file.name.replace(/\.pdf$/i, '');
|
||||
const outputName = `${safeName || 'ocr'}_chat.pdf`;
|
||||
return new File([blob], outputName, { type: 'application/pdf' });
|
||||
}
|
||||
51
frontend/src/core/services/chatbotService.ts
Normal file
51
frontend/src/core/services/chatbotService.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface ChatbotSessionPayload {
|
||||
sessionId?: string;
|
||||
documentId: string;
|
||||
userId?: string;
|
||||
text: string;
|
||||
metadata?: Record<string, string>;
|
||||
ocrRequested: boolean;
|
||||
warningsAccepted: boolean;
|
||||
}
|
||||
|
||||
export interface ChatbotSessionInfo {
|
||||
sessionId: string;
|
||||
documentId: string;
|
||||
alphaWarning: boolean;
|
||||
ocrRequested: boolean;
|
||||
maxCachedCharacters: number;
|
||||
createdAt: string;
|
||||
warnings?: string[];
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChatbotQueryPayload {
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
allowEscalation: boolean;
|
||||
}
|
||||
|
||||
export interface ChatbotMessageResponse {
|
||||
sessionId: string;
|
||||
modelUsed: string;
|
||||
confidence: number;
|
||||
answer: string;
|
||||
escalated: boolean;
|
||||
servedFromNanoOnly: boolean;
|
||||
cacheHit?: boolean;
|
||||
warnings?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function createChatbotSession(payload: ChatbotSessionPayload) {
|
||||
const { data } = await apiClient.post<ChatbotSessionInfo>('/api/internal/chatbot/session', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendChatbotPrompt(payload: ChatbotQueryPayload) {
|
||||
const { data } = await apiClient.post<ChatbotMessageResponse>('/api/internal/chatbot/query', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
39
frontend/src/core/services/pdfTextExtractor.ts
Normal file
39
frontend/src/core/services/pdfTextExtractor.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
|
||||
export interface ExtractedPdfText {
|
||||
text: string;
|
||||
pageCount: number;
|
||||
characterCount: number;
|
||||
}
|
||||
|
||||
export async function extractTextFromPdf(file: File): Promise<ExtractedPdfText> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
|
||||
try {
|
||||
let combinedText = '';
|
||||
for (let pageIndex = 1; pageIndex <= pdf.numPages; pageIndex += 1) {
|
||||
const page = await pdf.getPage(pageIndex);
|
||||
const content = await page.getTextContent();
|
||||
const pageText = content.items
|
||||
.map((item) => ('str' in item ? item.str : ''))
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
if (pageText.length > 0) {
|
||||
combinedText += `\n\n[Page ${pageIndex}]\n${pageText}`;
|
||||
}
|
||||
|
||||
page.cleanup();
|
||||
}
|
||||
|
||||
const text = combinedText.trim();
|
||||
return {
|
||||
text,
|
||||
pageCount: pdf.numPages,
|
||||
characterCount: text.length,
|
||||
};
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
}
|
||||
}
|
||||
46
frontend/src/core/tools/ChatbotAssistant.tsx
Normal file
46
frontend/src/core/tools/ChatbotAssistant.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Alert, Button, Stack, Text } from '@mantine/core';
|
||||
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatbot } from '@app/contexts/ChatbotContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
|
||||
const ChatbotAssistant = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openChat } = useChatbot();
|
||||
const { selectors } = useFileState();
|
||||
const files = selectors.getFiles();
|
||||
const preferredFileId = files[0]?.fileId;
|
||||
const hasAutoOpened = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAutoOpened.current) {
|
||||
openChat({ source: 'tool', fileId: preferredFileId });
|
||||
hasAutoOpened.current = true;
|
||||
}
|
||||
}, [openChat, preferredFileId]);
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="sm">
|
||||
<Alert color="yellow" icon={<WarningAmberRoundedIcon fontSize="small" />}>
|
||||
{t('chatbot.toolNotice', 'Chatbot lives inside the main workspace. Use the button below to focus the conversation pane on the left.')}
|
||||
</Alert>
|
||||
<Text>
|
||||
{t('chatbot.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.')}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<SmartToyRoundedIcon fontSize="small" />}
|
||||
onClick={() => openChat({ source: 'tool', fileId: preferredFileId })}
|
||||
>
|
||||
{t('chatbot.toolOpenButton', 'Open chat window')}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('chatbot.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.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotAssistant;
|
||||
@ -7,6 +7,7 @@ import {
|
||||
export type ToolKind = 'regular' | 'super' | 'link';
|
||||
|
||||
export const CORE_REGULAR_TOOL_IDS = [
|
||||
'chatbot',
|
||||
'certSign',
|
||||
'sign',
|
||||
'addPassword',
|
||||
@ -113,4 +114,3 @@ type Disjoint<A, B> = [A & B] extends [never] ? true : false;
|
||||
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
|
||||
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
|
||||
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user