diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx
index e65388ada..3a788fba8 100644
--- a/frontend/src/core/components/AppProviders.tsx
+++ b/frontend/src/core/components/AppProviders.tsx
@@ -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 { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
@@ -98,7 +99,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
- {children}
+
+ {children}
+
diff --git a/frontend/src/core/components/chatbot/ChatbotDrawer.tsx b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx
new file mode 100644
index 000000000..4610a3d64
--- /dev/null
+++ b/frontend/src/core/components/chatbot/ChatbotDrawer.tsx
@@ -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();
+ 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('');
+ const [sessionInfo, setSessionInfo] = useState(null);
+ const [contextStats, setContextStats] = useState<{ pageCount: number; characterCount: number } | null>(null);
+ const [messages, setMessages] = useState([]);
+ const [prompt, setPrompt] = useState('');
+ const [warnings, setWarnings] = useState([]);
+ const [noTextModalOpen, setNoTextModalOpen] = useState(false);
+ const [pendingOcrRetry, setPendingOcrRetry] = useState(false);
+ const scrollViewportRef = useRef(null);
+
+ const selectedFile = useMemo(
+ () => 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 (label: string, fn: () => Promise): Promise => {
+ 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 = (
+
+
+ {t('chatbot.title', 'Stirling PDF Bot')}
+ {t('chatbot.alphaBadge', 'Alpha')}
+
+ );
+
+ const assistantWarnings = warnings.filter(Boolean);
+
+ return (
+
+
+ }
+ 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.')}
+
+
+ handleAlphaAccept(event.currentTarget.checked)}
+ />
+
+
+
+ setNoTextModalOpen(false)}
+ title={t('chatbot.noTextTitle', 'No text detected in this PDF')}
+ centered
+ >
+
+
+ {t('chatbot.noTextBody', 'We could not find selectable text in this document. Would you like to run OCR to convert scanned pages into text?')}
+
+
+ } onClick={() => setNoTextModalOpen(false)}>
+ {t('chatbot.noTextDismiss', 'Maybe later')}
+
+ }
+ onClick={() => {
+ setNoTextModalOpen(false);
+ setRunOcr(true);
+ if (pendingOcrRetry) {
+ handleSessionStart(true);
+ }
+ }}
+ >
+ {t('chatbot.noTextRunOcr', 'Run OCR and retry')}
+
+
+
+
+
+ );
+};
+
+export default ChatbotDrawer;
diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
index c02de9278..c454dc6e0 100644
--- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
+++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
@@ -7,12 +7,19 @@ 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';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
export function useViewerRightRailButtons() {
const { t, i18n } = 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(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
@@ -24,6 +31,7 @@ export function useViewerRightRailButtons() {
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
+ const chatLabel = t('chatbot.viewerButton', 'Stirling Bot');
const viewerButtons = useMemo(() => {
return [
@@ -58,6 +66,20 @@ export function useViewerRightRailButtons() {
)
},
+ {
+ id: 'viewer-chatbot',
+ icon: ,
+ 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,
@@ -136,7 +158,22 @@ export function useViewerRightRailButtons() {
)
}
];
- }, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, tooltipPosition]);
+ }, [
+ t,
+ i18n.language,
+ viewer,
+ isPanning,
+ searchLabel,
+ panLabel,
+ rotateLeftLabel,
+ rotateRightLabel,
+ sidebarLabel,
+ chatLabel,
+ openChat,
+ activeFile?.fileId,
+ bookmarkLabel,
+ tooltipPosition,
+ ]);
useRightRailButtons(viewerButtons);
}
diff --git a/frontend/src/core/contexts/ChatbotContext.tsx b/frontend/src/core/contexts/ChatbotContext.tsx
new file mode 100644
index 000000000..b31d31b57
--- /dev/null
+++ b/frontend/src/core/contexts/ChatbotContext.tsx
@@ -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(undefined);
+
+export function ChatbotProvider({ children }: { children: ReactNode }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [source, setSource] = useState('viewer');
+ const [preferredFileId, setPreferredFileId] = useState();
+
+ 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 {children};
+}
+
+export function useChatbot() {
+ const context = useContext(ChatbotContext);
+ if (!context) {
+ throw new Error('useChatbot must be used within a ChatbotProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx
index cda99dc40..4f31495ef 100644
--- a/frontend/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx
@@ -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";
@@ -163,6 +164,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
automationSettings: null
},
+ chatbot: {
+ icon: ,
+ 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: ,
name: t("home.merge.title", "Merge"),
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index 04174d29a..11d7e58a7 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
+import ChatbotDrawer from "@app/components/chatbot/ChatbotDrawer";
import "@app/pages/HomePage.css";
@@ -297,6 +298,7 @@ export default function HomePage() {
)}
+
);
}
diff --git a/frontend/src/core/services/chatbotOcrService.ts b/frontend/src/core/services/chatbotOcrService.ts
new file mode 100644
index 000000000..df62cf64d
--- /dev/null
+++ b/frontend/src/core/services/chatbotOcrService.ts
@@ -0,0 +1,65 @@
+import apiClient from '@app/services/apiClient';
+
+const LANGUAGE_MAP: Record = {
+ 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 {
+ 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(
+ '/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' });
+}
diff --git a/frontend/src/core/services/chatbotService.ts b/frontend/src/core/services/chatbotService.ts
new file mode 100644
index 000000000..56fb1f03f
--- /dev/null
+++ b/frontend/src/core/services/chatbotService.ts
@@ -0,0 +1,51 @@
+import apiClient from '@app/services/apiClient';
+
+export interface ChatbotSessionPayload {
+ sessionId?: string;
+ documentId: string;
+ userId?: string;
+ text: string;
+ metadata?: Record;
+ ocrRequested: boolean;
+ warningsAccepted: boolean;
+}
+
+export interface ChatbotSessionInfo {
+ sessionId: string;
+ documentId: string;
+ alphaWarning: boolean;
+ ocrRequested: boolean;
+ maxCachedCharacters: number;
+ createdAt: string;
+ warnings?: string[];
+ metadata?: Record;
+}
+
+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;
+}
+
+export async function createChatbotSession(payload: ChatbotSessionPayload) {
+ const { data } = await apiClient.post('/api/internal/chatbot/session', payload);
+ return data;
+}
+
+export async function sendChatbotPrompt(payload: ChatbotQueryPayload) {
+ const { data } = await apiClient.post('/api/internal/chatbot/query', payload);
+ return data;
+}
+
diff --git a/frontend/src/core/services/pdfTextExtractor.ts b/frontend/src/core/services/pdfTextExtractor.ts
new file mode 100644
index 000000000..4e924f804
--- /dev/null
+++ b/frontend/src/core/services/pdfTextExtractor.ts
@@ -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 {
+ 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);
+ }
+}
diff --git a/frontend/src/core/tools/ChatbotAssistant.tsx b/frontend/src/core/tools/ChatbotAssistant.tsx
new file mode 100644
index 000000000..a02f53406
--- /dev/null
+++ b/frontend/src/core/tools/ChatbotAssistant.tsx
@@ -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 (
+
+ }>
+ {t('chatbot.toolNotice', 'Chatbot lives inside the main workspace. Use the button below to focus the conversation pane on the left.')}
+
+
+ {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.')}
+
+ }
+ onClick={() => openChat({ source: 'tool', fileId: preferredFileId })}
+ >
+ {t('chatbot.toolOpenButton', 'Open chat window')}
+
+
+ {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.')}
+
+
+ );
+};
+
+export default ChatbotAssistant;
diff --git a/frontend/src/core/types/toolId.ts b/frontend/src/core/types/toolId.ts
index 23b03a871..d7297767b 100644
--- a/frontend/src/core/types/toolId.ts
+++ b/frontend/src/core/types/toolId.ts
@@ -7,6 +7,7 @@ import {
export type ToolKind = 'regular' | 'super' | 'link';
export const CORE_REGULAR_TOOL_IDS = [
+ 'chatbot',
'certSign',
'sign',
'addText',