chatbot UI

This commit is contained in:
Dario Ghunney Ware 2025-11-04 17:44:34 +00:00
parent 7b9f83b08f
commit a8d105430f
12 changed files with 877 additions and 3 deletions

View File

@ -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."
} }
} }

View File

@ -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>

View 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;

View File

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

View 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;
}

View File

@ -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"),

View File

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

View 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' });
}

View 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;
}

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

View 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;

View File

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