mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
UI elements
This commit is contained in:
parent
f6ad398fb3
commit
1973c55d10
@ -626,6 +626,8 @@ public class ApplicationProperties {
|
|||||||
private String primary = "gpt-5-nano";
|
private String primary = "gpt-5-nano";
|
||||||
private String fallback = "gpt-5-mini";
|
private String fallback = "gpt-5-mini";
|
||||||
private String embedding = "text-embedding-3-small";
|
private String embedding = "text-embedding-3-small";
|
||||||
|
private long connectTimeoutMillis = 10000;
|
||||||
|
private long readTimeoutMillis = 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@ -52,9 +52,9 @@ dependencies {
|
|||||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||||
api 'com.github.ben-manes.caffeine:caffeine'
|
api 'com.github.ben-manes.caffeine:caffeine'
|
||||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
|
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
|
||||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
api 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||||
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
|
api 'org.springframework.ai:spring-ai-starter-model-ollama'
|
||||||
implementation 'org.springframework.ai:spring-ai-redis-store'
|
api 'org.springframework.ai:spring-ai-redis-store'
|
||||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
package stirling.software.proprietary.configuration;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnClass(RestClientCustomizer.class)
|
||||||
|
@ConditionalOnProperty(value = "spring.ai.openai.enabled", havingValue = "true")
|
||||||
|
public class ChatbotAiClientConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClientCustomizer chatbotRestClientCustomizer(
|
||||||
|
ApplicationProperties applicationProperties) {
|
||||||
|
long connectTimeout = resolveConnectTimeout(applicationProperties);
|
||||||
|
long readTimeout = resolveReadTimeout(applicationProperties);
|
||||||
|
return builder -> builder.requestFactory(createRequestFactory(connectTimeout, readTimeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JdkClientHttpRequestFactory createRequestFactory(
|
||||||
|
long connectTimeoutMillis, long readTimeoutMillis) {
|
||||||
|
HttpClient httpClient =
|
||||||
|
HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofMillis(connectTimeoutMillis))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
|
||||||
|
factory.setReadTimeout((int) readTimeoutMillis);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long resolveConnectTimeout(ApplicationProperties properties) {
|
||||||
|
long configured = resolveChatbot(properties).getModels().getConnectTimeoutMillis();
|
||||||
|
return configured > 0 ? configured : 30000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long resolveReadTimeout(ApplicationProperties properties) {
|
||||||
|
long configured = resolveChatbot(properties).getModels().getReadTimeoutMillis();
|
||||||
|
return configured > 0 ? configured : 120000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Chatbot resolveChatbot(ApplicationProperties properties) {
|
||||||
|
return Optional.ofNullable(properties)
|
||||||
|
.map(ApplicationProperties::getPremium)
|
||||||
|
.map(Premium::getProFeatures)
|
||||||
|
.map(ProFeatures::getChatbot)
|
||||||
|
.orElseGet(Chatbot::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -99,17 +99,16 @@ public class ChatbotController {
|
|||||||
|
|
||||||
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
|
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
|
||||||
List<String> warnings = new ArrayList<>();
|
List<String> warnings = new ArrayList<>();
|
||||||
if (settings.alphaWarning()) {
|
|
||||||
warnings.add("Chatbot feature is in alpha and may change.");
|
|
||||||
}
|
|
||||||
warnings.add("Image-based content is not supported yet.");
|
|
||||||
if (session != null && session.isImageContentDetected()) {
|
if (session != null && session.isImageContentDetected()) {
|
||||||
warnings.add("Detected images will be ignored until image support ships.");
|
warnings.add("Images detected - Images are not currently supported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings.add("Only extracted text is sent for analysis.");
|
warnings.add("Only extracted text is sent for analysis.");
|
||||||
if (session != null && session.isOcrRequested()) {
|
if (session != null && session.isOcrRequested()) {
|
||||||
warnings.add("OCR was requested – extra processing charges may apply.");
|
warnings.add("OCR was requested – extra processing charges may apply.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package stirling.software.proprietary.controller;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.eclipse.jetty.client.HttpResponseException;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -37,6 +38,14 @@ public class ChatbotExceptionHandler {
|
|||||||
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(HttpResponseException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleProvider(HttpResponseException ex) {
|
||||||
|
log.warn("Chatbot provider error", ex);
|
||||||
|
return buildResponse(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Chatbot provider rejected the request: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
|
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
|
||||||
Map<String, Object> payload =
|
Map<String, Object> payload =
|
||||||
Map.of(
|
Map.of(
|
||||||
|
|||||||
@ -204,7 +204,19 @@ public class ChatbotConversationService {
|
|||||||
List<ChatbotTextChunk> context,
|
List<ChatbotTextChunk> context,
|
||||||
Map<String, String> metadata) {
|
Map<String, String> metadata) {
|
||||||
Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata);
|
Prompt requestPrompt = buildPrompt(settings, model, prompt, session, context, metadata);
|
||||||
ChatResponse response = chatModel.call(requestPrompt);
|
ChatResponse response;
|
||||||
|
try {
|
||||||
|
response = chatModel.call(requestPrompt);
|
||||||
|
} catch (org.eclipse.jetty.client.HttpResponseException ex) {
|
||||||
|
throw new ChatbotException(
|
||||||
|
"Chat model rejected the request: " + sanitizeRemoteMessage(ex.getMessage()),
|
||||||
|
ex);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
throw new ChatbotException(
|
||||||
|
"Failed to contact chat model provider: "
|
||||||
|
+ sanitizeRemoteMessage(ex.getMessage()),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
String content =
|
String content =
|
||||||
Optional.ofNullable(response)
|
Optional.ofNullable(response)
|
||||||
.map(ChatResponse::getResults)
|
.map(ChatResponse::getResults)
|
||||||
@ -298,4 +310,11 @@ public class ChatbotConversationService {
|
|||||||
|
|
||||||
private record ModelReply(
|
private record ModelReply(
|
||||||
String answer, double confidence, boolean requiresEscalation, String rationale) {}
|
String answer, double confidence, boolean requiresEscalation, String rationale) {}
|
||||||
|
|
||||||
|
private String sanitizeRemoteMessage(String message) {
|
||||||
|
if (!StringUtils.hasText(message)) {
|
||||||
|
return "unexpected provider error";
|
||||||
|
}
|
||||||
|
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,20 @@ public class ChatbotIngestionService {
|
|||||||
if (chunkTexts.isEmpty()) {
|
if (chunkTexts.isEmpty()) {
|
||||||
throw new ChatbotException("Unable to split document text into retrievable chunks");
|
throw new ChatbotException("Unable to split document text into retrievable chunks");
|
||||||
}
|
}
|
||||||
EmbeddingResponse response = embeddingModel.embedForResponse(chunkTexts);
|
EmbeddingResponse response;
|
||||||
|
try {
|
||||||
|
response = embeddingModel.embedForResponse(chunkTexts);
|
||||||
|
} catch (org.eclipse.jetty.client.HttpResponseException ex) {
|
||||||
|
throw new ChatbotException(
|
||||||
|
"Embedding provider rejected the request: "
|
||||||
|
+ sanitizeRemoteMessage(ex.getMessage()),
|
||||||
|
ex);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
throw new ChatbotException(
|
||||||
|
"Failed to compute embeddings for chatbot ingestion: "
|
||||||
|
+ sanitizeRemoteMessage(ex.getMessage()),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
if (response.getResults().size() != chunkTexts.size()) {
|
if (response.getResults().size() != chunkTexts.size()) {
|
||||||
throw new ChatbotException("Mismatch between chunks and embedding results");
|
throw new ChatbotException("Mismatch between chunks and embedding results");
|
||||||
}
|
}
|
||||||
@ -165,4 +178,11 @@ public class ChatbotIngestionService {
|
|||||||
chunks.size());
|
chunks.size());
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String sanitizeRemoteMessage(String message) {
|
||||||
|
if (!StringUtils.hasText(message)) {
|
||||||
|
return "unexpected provider error";
|
||||||
|
}
|
||||||
|
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5140,5 +5140,59 @@
|
|||||||
"offline": "Backend Offline",
|
"offline": "Backend Offline",
|
||||||
"starting": "Backend starting up...",
|
"starting": "Backend starting up...",
|
||||||
"wait": "Please wait for the backend to finish launching and try again."
|
"wait": "Please wait for the backend to finish launching and try again."
|
||||||
|
},
|
||||||
|
"chatbot": {
|
||||||
|
"title": "Stirling PDF Bot",
|
||||||
|
"alphaBadge": "Alpha",
|
||||||
|
"alphaTitle": "Experimental feature",
|
||||||
|
"alphaDescription": "This Chatbot feature is in currently in alpha and is subject to change. Image-based content is not supported yet. Responses may be imperfect, so double-check important answers.",
|
||||||
|
"acceptAlphaLabel": "I understand this feature is experimental and image content is not supported yet.",
|
||||||
|
"fileLabel": "Document to query",
|
||||||
|
"filePlaceholder": "Select an uploaded PDF",
|
||||||
|
"noFiles": "Upload a PDF from File Manager to start chatting.",
|
||||||
|
"ocrToggle": "Run OCR before extracting text (uses more resources)",
|
||||||
|
"ocrHint": "Enable when your PDF is a scan or contains images.",
|
||||||
|
"refreshButton": "Re-sync document",
|
||||||
|
"startButton": "Send document to chat",
|
||||||
|
"sessionSummary": "Context summary",
|
||||||
|
"contextDetails": "{{pages}} pages · {{chars}} characters synced",
|
||||||
|
"conversationTitle": "Conversation",
|
||||||
|
"emptyState": "Ask a question about your PDF to start the conversation.",
|
||||||
|
"userLabel": "You",
|
||||||
|
"botLabel": "Stirling Bot",
|
||||||
|
"confidence": "Confidence: {{value}}%",
|
||||||
|
"modelTag": "Model: {{name}}",
|
||||||
|
"escalationToggle": "Allow upgrade to GPT5-Mini for complex prompts",
|
||||||
|
"promptPlaceholder": "Ask anything about this PDF…",
|
||||||
|
"promptCounter": "{{used}} / {{limit}} characters",
|
||||||
|
"sendButton": "Send",
|
||||||
|
"viewerButton": "Chat about this PDF",
|
||||||
|
"toolNotice": "Chatbot lives inside the main workspace. Use the button below to focus the conversation pane on the left.",
|
||||||
|
"toolDescription": "Ask Stirling Bot questions about any uploaded PDF. The assistant uses your extracted text, so make sure the correct document is selected inside the chat panel.",
|
||||||
|
"toolOpenButton": "Open chat window",
|
||||||
|
"toolHint": "The chat window slides in from the left. If it is already open, this button simply focuses it and passes along the currently selected PDF.",
|
||||||
|
"toolTitleMenu": "Chatbot (Alpha)",
|
||||||
|
"toolMenuDescription": "Chat with Stirling Bot about the contents of your PDF.",
|
||||||
|
"status": {
|
||||||
|
"runningOcr": "Running OCR and extracting text…",
|
||||||
|
"extracting": "Extracting text from PDF…",
|
||||||
|
"syncing": "Syncing document with Stirling Bot…"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"noFileTitle": "No PDF selected",
|
||||||
|
"noFileBody": "Please choose a document before starting the chatbot.",
|
||||||
|
"ackTitle": "Accept alpha notice",
|
||||||
|
"ackBody": "Please acknowledge the alpha warning before starting.",
|
||||||
|
"failedSessionTitle": "Could not prepare document",
|
||||||
|
"failedSessionBody": "We were unable to sync this PDF with the chatbot. Please try again.",
|
||||||
|
"failedPromptTitle": "Unable to ask question",
|
||||||
|
"failedPromptBody": "The chatbot could not process your question. Wait a moment and try again.",
|
||||||
|
"noSessionTitle": "Sync your document first",
|
||||||
|
"noSessionBody": "Send your PDF to the chatbot before asking questions."
|
||||||
|
},
|
||||||
|
"noTextTitle": "No text detected in this PDF",
|
||||||
|
"noTextBody": "We could not find selectable text in this document. Would you like to run OCR to convert scanned pages into text?",
|
||||||
|
"noTextDismiss": "Maybe later",
|
||||||
|
"noTextRunOcr": "Run OCR and retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@ -17,7 +15,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
|
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
|
||||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||||
@ -37,6 +35,7 @@ import {
|
|||||||
} from '@app/services/chatbotService';
|
} from '@app/services/chatbotService';
|
||||||
import { useToast } from '@app/components/toast';
|
import { useToast } from '@app/components/toast';
|
||||||
import type { StirlingFile } from '@app/types/fileContext';
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@ -61,14 +60,15 @@ const MAX_PROMPT_CHARS = 4000;
|
|||||||
const ChatbotDrawer = () => {
|
const ChatbotDrawer = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||||
const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot();
|
const { isOpen, closeChat, preferredFileId, setPreferredFileId } = useChatbot();
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const files = selectors.getFiles();
|
const files = selectors.getFiles();
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
|
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
|
||||||
const [alphaAccepted, setAlphaAccepted] = useState(false);
|
const [alphaAccepted, setAlphaAccepted] = useState(false);
|
||||||
const [runOcr, setRunOcr] = useState(false);
|
const [runOcr, setRunOcr] = useState(false);
|
||||||
const [allowEscalation, setAllowEscalation] = useState(true);
|
|
||||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||||
const [isSendingMessage, setIsSendingMessage] = useState(false);
|
const [isSendingMessage, setIsSendingMessage] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string>('');
|
const [statusMessage, setStatusMessage] = useState<string>('');
|
||||||
@ -80,6 +80,7 @@ const ChatbotDrawer = () => {
|
|||||||
const [noTextModalOpen, setNoTextModalOpen] = useState(false);
|
const [noTextModalOpen, setNoTextModalOpen] = useState(false);
|
||||||
const [pendingOcrRetry, setPendingOcrRetry] = useState(false);
|
const [pendingOcrRetry, setPendingOcrRetry] = useState(false);
|
||||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [panelAnchor, setPanelAnchor] = useState<{ right: number; top: number } | null>(null);
|
||||||
|
|
||||||
const selectedFile = useMemo<StirlingFile | undefined>(
|
const selectedFile = useMemo<StirlingFile | undefined>(
|
||||||
() => files.find((file) => file.fileId === selectedFileId),
|
() => files.find((file) => file.fileId === selectedFileId),
|
||||||
@ -128,6 +129,34 @@ const ChatbotDrawer = () => {
|
|||||||
}
|
}
|
||||||
}, [sessionInfo, selectedFileId]);
|
}, [sessionInfo, selectedFileId]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isMobile || !isOpen) {
|
||||||
|
setPanelAnchor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const panelEl = sidebarRefs.toolPanelRef.current;
|
||||||
|
if (!panelEl) {
|
||||||
|
setPanelAnchor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updateAnchor = () => {
|
||||||
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
setPanelAnchor({
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateAnchor();
|
||||||
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateAnchor()) : null;
|
||||||
|
observer?.observe(panelEl);
|
||||||
|
const handleResize = () => updateAnchor();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [isMobile, isOpen, sidebarRefs.toolPanelRef]);
|
||||||
|
|
||||||
const handleAlphaAccept = (checked: boolean) => {
|
const handleAlphaAccept = (checked: boolean) => {
|
||||||
setAlphaAccepted(checked);
|
setAlphaAccepted(checked);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -264,7 +293,7 @@ const ChatbotDrawer = () => {
|
|||||||
const reply = await sendChatbotPrompt({
|
const reply = await sendChatbotPrompt({
|
||||||
sessionId: sessionInfo.sessionId,
|
sessionId: sessionInfo.sessionId,
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
allowEscalation,
|
allowEscalation: true,
|
||||||
});
|
});
|
||||||
setWarnings(reply.warnings ?? []);
|
setWarnings(reply.warnings ?? []);
|
||||||
const assistant = convertAssistantMessage(reply);
|
const assistant = convertAssistantMessage(reply);
|
||||||
@ -302,6 +331,22 @@ const ChatbotDrawer = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage;
|
const disablePromptInput = !sessionInfo || isStartingSession || isSendingMessage;
|
||||||
|
const canSend = !disablePromptInput && prompt.trim().length > 0;
|
||||||
|
|
||||||
|
const handlePromptKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.altKey
|
||||||
|
) {
|
||||||
|
if (canSend) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const drawerTitle = (
|
const drawerTitle = (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@ -313,50 +358,148 @@ const ChatbotDrawer = () => {
|
|||||||
|
|
||||||
const assistantWarnings = warnings.filter(Boolean);
|
const assistantWarnings = warnings.filter(Boolean);
|
||||||
|
|
||||||
|
const safeViewportWidth =
|
||||||
|
viewportWidth || (typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||||
|
const safeViewportHeight =
|
||||||
|
viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 900);
|
||||||
|
const desktopLeft = !isMobile ? (panelAnchor ? panelAnchor.right + 16 : 280) : undefined;
|
||||||
|
const desktopBottom = !isMobile ? 24 : undefined;
|
||||||
|
const desktopWidth = !isMobile
|
||||||
|
? Math.min(440, Math.max(320, safeViewportWidth - (desktopLeft ?? 24) - 240))
|
||||||
|
: undefined;
|
||||||
|
const desktopHeightPx = !isMobile
|
||||||
|
? Math.max(520, Math.min(safeViewportHeight - 48, Math.round(safeViewportHeight * 0.85)))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const renderMessageBubble = (message: ChatMessage) => {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const bubbleColor = isUser ? '#1f7ae0' : '#f3f4f6';
|
||||||
|
const textColor = isUser ? '#fff' : '#1f1f1f';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Box
|
||||||
|
key={message.id + message.role + message.createdAt.getTime()}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
maw="85%"
|
||||||
|
bg={bubbleColor}
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
borderTopRightRadius: isUser ? 4 : 14,
|
||||||
|
borderTopLeftRadius: isUser ? 14 : 4,
|
||||||
|
boxShadow: '0 2px 12px rgba(16,24,40,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb={4} gap="xs">
|
||||||
|
<Text size="xs" c={isUser ? 'rgba(255,255,255,0.8)' : 'dimmed'} tt="uppercase">
|
||||||
|
{isUser ? t('chatbot.userLabel', 'You') : t('chatbot.botLabel', 'Stirling Bot')}
|
||||||
|
</Text>
|
||||||
|
{!isUser && message.confidence !== undefined && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={message.confidence >= 0.6 ? 'green' : 'yellow'}
|
||||||
|
>
|
||||||
|
{t('chatbot.confidence', 'Confidence: {{value}}%', {
|
||||||
|
value: Math.round(message.confidence * 100),
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c={textColor} style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{message.content}
|
||||||
|
</Text>
|
||||||
|
{!isUser && message.modelUsed && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
opened={isOpen}
|
opened={isOpen}
|
||||||
onClose={closeChat}
|
onClose={closeChat}
|
||||||
position="left"
|
withCloseButton
|
||||||
overlayProps={{ opacity: 0.65, blur: 3 }}
|
radius="lg"
|
||||||
size={isMobile ? '100%' : 420}
|
overlayProps={{ opacity: 0.5, blur: 2 }}
|
||||||
|
fullScreen={isMobile}
|
||||||
|
centered={isMobile}
|
||||||
title={drawerTitle}
|
title={drawerTitle}
|
||||||
withinPortal
|
styles={{
|
||||||
closeOnClickOutside={false}
|
content: {
|
||||||
|
width: isMobile ? '100%' : desktopWidth,
|
||||||
|
left: isMobile ? undefined : desktopLeft,
|
||||||
|
right: isMobile ? 0 : undefined,
|
||||||
|
margin: isMobile ? undefined : 0,
|
||||||
|
top: isMobile ? undefined : undefined,
|
||||||
|
bottom: isMobile ? 0 : desktopBottom,
|
||||||
|
position: isMobile ? undefined : 'fixed',
|
||||||
|
height: isMobile ? '100%' : desktopHeightPx ? `${desktopHeightPx}px` : '75vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingTop: 'var(--mantine-spacing-md)',
|
||||||
|
paddingBottom: 'var(--mantine-spacing-md)',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
transitionProps={{ transition: 'slide-left', duration: 200 }}
|
||||||
>
|
>
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="sm" h="100%" style={{ minHeight: 0 }}>
|
||||||
<Alert color="yellow" icon={<WarningAmberRoundedIcon fontSize="small" />}
|
<Box
|
||||||
title={t('chatbot.alphaTitle', 'Experimental feature')}
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--bg-subtle)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
|
||||||
|
<Box>
|
||||||
|
<Text fw={600}>{t('chatbot.alphaTitle', 'Experimental feature')}</Text>
|
||||||
|
<Text size="sm">
|
||||||
{t('chatbot.alphaDescription', 'This chatbot is in alpha. It currently ignores images and may produce inaccurate answers. Your PDF text stays local until you confirm you want to chat.')}
|
{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>
|
</Text>
|
||||||
|
</Box>
|
||||||
<Switch
|
</Box>
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<Group align="flex-end" justify="space-between" gap="md" wrap="wrap">
|
||||||
<Select
|
<Select
|
||||||
label={t('chatbot.fileLabel', 'Document to query')}
|
label={t('chatbot.fileLabel', 'Document')}
|
||||||
placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')}
|
placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')}
|
||||||
data={fileOptions}
|
data={fileOptions}
|
||||||
value={selectedFileId}
|
value={selectedFileId}
|
||||||
onChange={(value) => setSelectedFileId(value || undefined)}
|
onChange={(value) => setSelectedFileId(value || undefined)}
|
||||||
nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')}
|
nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')}
|
||||||
|
style={{ flex: '1 1 200px' }}
|
||||||
|
/>
|
||||||
|
<Stack gap={4} style={{ minWidth: 160 }}>
|
||||||
|
<Switch
|
||||||
|
checked={alphaAccepted}
|
||||||
|
label={t('chatbot.acceptAlphaLabel', 'Alpha notice acknowledged')}
|
||||||
|
onChange={(event) => handleAlphaAccept(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={runOcr}
|
checked={runOcr}
|
||||||
onChange={(event) => setRunOcr(event.currentTarget.checked)}
|
onChange={(event) => setRunOcr(event.currentTarget.checked)}
|
||||||
label={t('chatbot.ocrToggle', 'Run OCR before extracting text (uses more resources)')}
|
label={t('chatbot.ocrToggle', 'Run OCR before extracting text')}
|
||||||
/>
|
/>
|
||||||
<Tooltip label={t('chatbot.ocrHint', 'Enable when your PDF is a scan or contains images.')}>
|
</Stack>
|
||||||
<ActionIcon variant="subtle" aria-label={t('chatbot.ocrHint', 'OCR hint')}>
|
|
||||||
<SmartToyRoundedIcon fontSize="small" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -373,7 +516,16 @@ const ChatbotDrawer = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<Alert color="blue">{statusMessage}</Alert>
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'var(--bg-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="blue">{statusMessage}</Text>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionInfo && contextStats && (
|
{sessionInfo && contextStats && (
|
||||||
@ -388,70 +540,55 @@ const ChatbotDrawer = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{assistantWarnings.length > 0 && (
|
|
||||||
<Alert color="yellow">
|
|
||||||
<Stack gap={4}>
|
|
||||||
{assistantWarnings.map((warning) => (
|
|
||||||
<Text key={warning} size="sm">{warning}</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider label={t('chatbot.conversationTitle', 'Conversation')} />
|
<Divider label={t('chatbot.conversationTitle', 'Conversation')} />
|
||||||
|
|
||||||
<ScrollArea viewportRef={scrollViewportRef} style={{ flex: 1 }}>
|
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Stack gap="sm" pr="sm">
|
<ScrollArea viewportRef={scrollViewportRef} style={{ height: '100%' }}>
|
||||||
|
<Stack gap="sm" pr="xs">
|
||||||
|
{assistantWarnings.length > 0 &&
|
||||||
|
assistantWarnings.map((warning) => (
|
||||||
|
<Box
|
||||||
|
key={warning}
|
||||||
|
p="sm"
|
||||||
|
bg="var(--bg-muted)"
|
||||||
|
style={{ borderRadius: 12, border: '1px dashed var(--border-subtle)' }}
|
||||||
|
>
|
||||||
|
<Group gap="xs" align="flex-start">
|
||||||
|
<WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
|
||||||
|
<Text size="sm">{warning}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
|
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => (
|
{messages.map(renderMessageBubble)}
|
||||||
<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>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack
|
||||||
<Switch
|
gap="xs"
|
||||||
checked={allowEscalation}
|
style={{
|
||||||
onChange={(event) => setAllowEscalation(event.currentTarget.checked)}
|
flexShrink: 0,
|
||||||
label={t('chatbot.escalationToggle', 'Allow upgrade to GPT5-Mini for complex prompts')}
|
border: '1px solid var(--border-subtle)',
|
||||||
/>
|
borderRadius: 12,
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--bg-toolbar)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
|
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
|
||||||
minRows={3}
|
minRows={2}
|
||||||
|
autosize
|
||||||
|
maxRows={6}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
maxLength={MAX_PROMPT_CHARS}
|
maxLength={MAX_PROMPT_CHARS}
|
||||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||||
disabled={disablePromptInput}
|
disabled={disablePromptInput}
|
||||||
|
onKeyDown={handlePromptKeyDown}
|
||||||
/>
|
/>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@ -464,13 +601,14 @@ const ChatbotDrawer = () => {
|
|||||||
rightSection={<SendRoundedIcon fontSize="small" />}
|
rightSection={<SendRoundedIcon fontSize="small" />}
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
loading={isSendingMessage}
|
loading={isSendingMessage}
|
||||||
disabled={disablePromptInput || prompt.trim().length === 0}
|
disabled={!canSend}
|
||||||
>
|
>
|
||||||
{t('chatbot.sendButton', 'Send')}
|
{t('chatbot.sendButton', 'Send')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={noTextModalOpen}
|
opened={noTextModalOpen}
|
||||||
@ -501,7 +639,7 @@ const ChatbotDrawer = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Drawer>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
|||||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||||
|
|
||||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||||
|
import { useChatbot } from '@app/contexts/ChatbotContext';
|
||||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||||
|
|
||||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||||
@ -41,6 +42,7 @@ export default function RightRail() {
|
|||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
const { toggleTheme } = useRainbowThemeContext();
|
const { toggleTheme } = useRainbowThemeContext();
|
||||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||||
|
const { openChat } = useChatbot();
|
||||||
|
|
||||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||||
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
||||||
@ -55,6 +57,8 @@ export default function RightRail() {
|
|||||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||||
const exportState = viewerContext?.getExportState?.();
|
const exportState = viewerContext?.getExportState?.();
|
||||||
|
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
|
||||||
|
const viewerActiveFile = activeFiles[viewerContext?.activeFileIndex ?? 0];
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
const totalItems = useMemo(() => {
|
||||||
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
||||||
@ -222,6 +226,24 @@ export default function RightRail() {
|
|||||||
</ActionIcon>,
|
</ActionIcon>,
|
||||||
downloadTooltip
|
downloadTooltip
|
||||||
)}
|
)}
|
||||||
|
{renderWithTooltip(
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
radius="md"
|
||||||
|
className="right-rail-icon"
|
||||||
|
onClick={() => {
|
||||||
|
if (viewerActiveFile) {
|
||||||
|
openChat({ source: 'viewer', fileId: viewerActiveFile.fileId });
|
||||||
|
} else {
|
||||||
|
openChat({ source: 'viewer' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!viewerActiveFile}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />
|
||||||
|
</ActionIcon>,
|
||||||
|
chatLabel
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="right-rail-spacer" />
|
<div className="right-rail-spacer" />
|
||||||
|
|||||||
@ -7,17 +7,14 @@ import LocalIcon from '@app/components/shared/LocalIcon';
|
|||||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
import { SearchInterface } from '@app/components/viewer/SearchInterface';
|
||||||
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
|
||||||
import { useChatbot } from '@app/contexts/ChatbotContext';
|
|
||||||
import { useFileState } from '@app/contexts/FileContext';
|
import { useFileState } from '@app/contexts/FileContext';
|
||||||
|
|
||||||
export function useViewerRightRailButtons() {
|
export function useViewerRightRailButtons() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
const { openChat } = useChatbot();
|
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
|
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
|
||||||
const activeFile = files[viewer.activeFileIndex] || files[0];
|
|
||||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||||
|
|
||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
@ -26,7 +23,6 @@ export function useViewerRightRailButtons() {
|
|||||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||||
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
|
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
return [
|
return [
|
||||||
@ -61,20 +57,6 @@ export function useViewerRightRailButtons() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'viewer-chatbot',
|
|
||||||
icon: <LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />,
|
|
||||||
tooltip: chatLabel,
|
|
||||||
ariaLabel: chatLabel,
|
|
||||||
section: 'top' as const,
|
|
||||||
order: 15,
|
|
||||||
disabled: !activeFile,
|
|
||||||
onClick: () => {
|
|
||||||
if (activeFile) {
|
|
||||||
openChat({ source: 'viewer', fileId: activeFile.fileId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'viewer-pan-mode',
|
id: 'viewer-pan-mode',
|
||||||
tooltip: panLabel,
|
tooltip: panLabel,
|
||||||
@ -150,9 +132,6 @@ export function useViewerRightRailButtons() {
|
|||||||
rotateLeftLabel,
|
rotateLeftLabel,
|
||||||
rotateRightLabel,
|
rotateRightLabel,
|
||||||
sidebarLabel,
|
sidebarLabel,
|
||||||
chatLabel,
|
|
||||||
openChat,
|
|
||||||
activeFile?.fileId,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user