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": {
|
"replaceColorPdf": {
|
||||||
"tags": "Replace Color,Page operations,Back end,server side"
|
"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 { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
|
import { ChatbotProvider } from "@app/contexts/ChatbotContext";
|
||||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||||
@ -68,7 +69,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>
|
||||||
{children}
|
<ChatbotProvider>
|
||||||
|
{children}
|
||||||
|
</ChatbotProvider>
|
||||||
</AdminTourOrchestrationProvider>
|
</AdminTourOrchestrationProvider>
|
||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</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 { 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';
|
||||||
|
|
||||||
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 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);
|
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
|
||||||
@ -19,6 +26,7 @@ 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 [
|
||||||
@ -53,6 +61,20 @@ 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,
|
||||||
@ -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);
|
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 CompressPdfPanel from "@app/tools/Compress";
|
||||||
import OCRPanel from "@app/tools/OCR";
|
import OCRPanel from "@app/tools/OCR";
|
||||||
import ConvertPanel from "@app/tools/Convert";
|
import ConvertPanel from "@app/tools/Convert";
|
||||||
|
import ChatbotAssistant from "@app/tools/ChatbotAssistant";
|
||||||
import Sanitize from "@app/tools/Sanitize";
|
import Sanitize from "@app/tools/Sanitize";
|
||||||
import AddPassword from "@app/tools/AddPassword";
|
import AddPassword from "@app/tools/AddPassword";
|
||||||
import ChangePermissions from "@app/tools/ChangePermissions";
|
import ChangePermissions from "@app/tools/ChangePermissions";
|
||||||
@ -154,6 +155,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
supportsAutomate: false,
|
supportsAutomate: false,
|
||||||
automationSettings: null
|
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: {
|
merge: {
|
||||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.merge.title", "Merge"),
|
name: t("home.merge.title", "Merge"),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
|||||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||||
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
||||||
|
import ChatbotDrawer from "@app/components/chatbot/ChatbotDrawer";
|
||||||
|
|
||||||
import "@app/pages/HomePage.css";
|
import "@app/pages/HomePage.css";
|
||||||
|
|
||||||
@ -280,6 +281,7 @@ export default function HomePage() {
|
|||||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
<ChatbotDrawer />
|
||||||
</div>
|
</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 type ToolKind = 'regular' | 'super' | 'link';
|
||||||
|
|
||||||
export const CORE_REGULAR_TOOL_IDS = [
|
export const CORE_REGULAR_TOOL_IDS = [
|
||||||
|
'chatbot',
|
||||||
'certSign',
|
'certSign',
|
||||||
'sign',
|
'sign',
|
||||||
'addPassword',
|
'addPassword',
|
||||||
@ -113,4 +114,3 @@ type Disjoint<A, B> = [A & B] extends [never] ? true : false;
|
|||||||
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
|
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
|
||||||
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
|
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
|
||||||
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
|
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user