mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a4c0363a | ||
|
|
d3f4a40f68 | ||
|
|
c9cd6404ae | ||
|
|
9e9179015e | ||
|
|
8139987919 | ||
|
|
f4b39101ac | ||
|
|
f82020a3b7 | ||
|
|
cb0f7c7720 | ||
|
|
3695a9a70a | ||
|
|
98d4949930 | ||
|
|
6dcf20b9c9 | ||
|
|
949e8eb2c3 | ||
|
|
e0ad76eeab | ||
|
|
d502e90f11 | ||
|
|
1325196d75 | ||
|
|
c5030a543a | ||
|
|
58bfd8bee0 |
@@ -590,6 +590,7 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private Chatbot chatbot = new Chatbot();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -608,6 +609,61 @@ public class ApplicationProperties {
|
||||
: producer;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Chatbot {
|
||||
private boolean enabled;
|
||||
private boolean alphaWarning = true;
|
||||
private Cache cache = new Cache();
|
||||
private Models models = new Models();
|
||||
private Rag rag = new Rag();
|
||||
private Ocr ocr = new Ocr();
|
||||
private Audit audit = new Audit();
|
||||
private long maxPromptCharacters = 4000;
|
||||
private double minConfidenceNano = 0.65;
|
||||
private Usage usage = new Usage();
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
private long ttlMinutes = 720;
|
||||
private long maxEntries = 200;
|
||||
private long maxDocumentCharacters = 200000;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Models {
|
||||
private String provider = "openai";
|
||||
private String primary = "gpt-5-nano";
|
||||
private String fallback = "gpt-5-mini";
|
||||
private String embedding = "text-embedding-3-small";
|
||||
private double topP = 0.95;
|
||||
private long connectTimeoutMillis = 10000;
|
||||
private long readTimeoutMillis = 60000;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Rag {
|
||||
private int chunkSizeTokens = 512;
|
||||
private int chunkOverlapTokens = 128;
|
||||
private int topK = 8;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Ocr {
|
||||
private boolean enabledByDefault;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Audit {
|
||||
private boolean enabled = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Usage {
|
||||
private long perUserMonthlyTokens = 200000;
|
||||
private double warnAtRatio = 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Unified signature image controller that works for both authenticated and unauthenticated users.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
|
||||
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
|
||||
* signature management (save/delete), see proprietary SignatureController.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
|
||||
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
|
||||
* authenticated signature management (save/delete), see proprietary SignatureController.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
|
||||
@@ -4,9 +4,8 @@ logging.level.org.springframework.security=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
|
||||
logging.level.stirling.software.common.service.JobExecutorService=INFO
|
||||
@@ -52,9 +51,39 @@ server.servlet.session.timeout:30m
|
||||
springdoc.api-docs.path=/v1/api-docs
|
||||
# Set the URL of the OpenAPI JSON for the Swagger UI
|
||||
springdoc.swagger-ui.url=/v1/api-docs
|
||||
springdoc.swagger-ui.path=/swagger-ui.html
|
||||
|
||||
# Spring AI OpenAI Configuration
|
||||
# Uses GPT-5-nano as primary model and GPT-5-mini as fallback (configured in settings.yml)
|
||||
spring.ai.openai.enabled=true
|
||||
#spring.ai.openai.api-key=# todo <API-KEY-HERE>
|
||||
spring.ai.openai.base-url=https://api.openai.com
|
||||
spring.ai.openai.chat.enabled=true
|
||||
spring.ai.openai.chat.options.model=gpt-5-nano
|
||||
# Note: Some models only support default temperature value of 1.0
|
||||
spring.ai.openai.chat.options.temperature=1.0
|
||||
# For newer models, use max-completion-tokens instead of max-tokens
|
||||
spring.ai.openai.chat.options.max-completion-tokens=4000
|
||||
spring.ai.openai.embedding.enabled=true
|
||||
spring.ai.openai.embedding.options.model=text-embedding-ada-002
|
||||
# Increase timeout for OpenAI API calls (default is 10 seconds)
|
||||
spring.ai.openai.chat.options.connection-timeout=60s
|
||||
spring.ai.openai.chat.options.read-timeout=60s
|
||||
spring.ai.openai.embedding.options.connection-timeout=60s
|
||||
spring.ai.openai.embedding.options.read-timeout=60s
|
||||
|
||||
# Spring AI Ollama Configuration (disabled to avoid bean conflicts)
|
||||
spring.ai.ollama.enabled=false
|
||||
spring.ai.ollama.base-url=http://localhost:11434
|
||||
spring.ai.ollama.chat.enabled=false
|
||||
spring.ai.ollama.chat.options.model=llama3
|
||||
spring.ai.ollama.chat.options.temperature=1.0
|
||||
spring.ai.ollama.embedding.enabled=false
|
||||
spring.ai.ollama.embedding.options.model=nomic-embed-text
|
||||
|
||||
# Force OpenAPI 3.0 specification version
|
||||
springdoc.swagger-ui.path=/swagger-ui.html
|
||||
springdoc.api-docs.version=OPENAPI_3_0
|
||||
|
||||
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
|
||||
posthog.host=https://eu.i.posthog.com
|
||||
|
||||
|
||||
@@ -91,6 +91,32 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
chatbot:
|
||||
enabled: false # Master toggle for Stirling PDF chatbot feature
|
||||
alphaWarning: true # Display alpha-state warning before any processing
|
||||
cache:
|
||||
ttlMinutes: 720 # Cache entry lifetime (12h)
|
||||
maxEntries: 200 # Maximum number of cached documents per node
|
||||
maxDocumentCharacters: 600000 # Reject uploads exceeding this character count
|
||||
models:
|
||||
primary: gpt-5-nano # Default lightweight model
|
||||
fallback: gpt-5-mini # Escalation model for complex prompts
|
||||
embedding: text-embedding-3-small # Embedding model for vector store usage
|
||||
temperature: 0.2 # Sampling temperature for LLM responses
|
||||
topP: 0.95 # Top-p (nucleus) sampling for LLM responses
|
||||
rag:
|
||||
chunkSizeTokens: 512 # Token window used when chunking text
|
||||
chunkOverlapTokens: 128 # Overlap between successive chunks
|
||||
topK: 8 # Number of chunks to retrieve per query
|
||||
ocr:
|
||||
enabledByDefault: false # Whether OCR pre-processing is opted-in automatically
|
||||
audit:
|
||||
enabled: true # Emit audit records for chatbot activity
|
||||
maxPromptCharacters: 4000 # Server-side guardrail for incoming prompts
|
||||
minConfidenceNano: 0.65 # Minimum nano confidence to avoid escalation
|
||||
usage:
|
||||
perUserMonthlyTokens: 200000 # Monthly RAG + chat token budget per user
|
||||
warnAtRatio: 0.7 # Warn users when usage exceeds 70%
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
|
||||
@@ -41,6 +41,8 @@ dependencies {
|
||||
api 'org.springframework:spring-webmvc'
|
||||
api 'org.springframework.session:spring-session-core'
|
||||
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
|
||||
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
|
||||
api 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
@@ -50,12 +52,16 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
|
||||
implementation 'org.springframework.ai:spring-ai-starter-vector-store-redis'
|
||||
implementation 'redis.clients:jedis:5.1.0'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.82'
|
||||
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
|
||||
// implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' // Removed - UI moved to React frontend
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import redis.clients.jedis.Connection;
|
||||
import redis.clients.jedis.DefaultJedisClientConfig;
|
||||
import redis.clients.jedis.HostAndPort;
|
||||
import redis.clients.jedis.JedisClientConfig;
|
||||
import redis.clients.jedis.JedisPooled;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public class ChatbotRedisConfig {
|
||||
|
||||
@Value("${spring.data.redis.host:localhost}")
|
||||
private String redisHost;
|
||||
|
||||
@Value("${spring.data.redis.port:6379}")
|
||||
private int redisPort;
|
||||
|
||||
@Value("${spring.data.redis.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.timeout:60000}")
|
||||
private int redisTimeout;
|
||||
|
||||
@Value("${spring.data.redis.ssl.enabled:false}")
|
||||
private boolean sslEnabled;
|
||||
|
||||
@Bean
|
||||
public JedisPooled jedisPooled() {
|
||||
try {
|
||||
log.info("Creating JedisPooled connection to {}:{}", redisHost, redisPort);
|
||||
|
||||
// Create pool configuration
|
||||
GenericObjectPoolConfig<Connection> poolConfig = new GenericObjectPoolConfig<>();
|
||||
poolConfig.setMaxTotal(50);
|
||||
poolConfig.setMaxIdle(25);
|
||||
poolConfig.setMinIdle(5);
|
||||
poolConfig.setTestOnBorrow(true);
|
||||
poolConfig.setTestOnReturn(true);
|
||||
poolConfig.setTestWhileIdle(true);
|
||||
|
||||
// Create host and port configuration
|
||||
HostAndPort hostAndPort = new HostAndPort(redisHost, redisPort);
|
||||
|
||||
// Create client configuration with authentication if password is provided
|
||||
JedisClientConfig clientConfig;
|
||||
if (redisPassword != null && !redisPassword.trim().isEmpty()) {
|
||||
clientConfig =
|
||||
DefaultJedisClientConfig.builder()
|
||||
.password(redisPassword)
|
||||
.connectionTimeoutMillis(redisTimeout)
|
||||
.socketTimeoutMillis(redisTimeout)
|
||||
.ssl(sslEnabled)
|
||||
.build();
|
||||
} else {
|
||||
clientConfig =
|
||||
DefaultJedisClientConfig.builder()
|
||||
.connectionTimeoutMillis(redisTimeout)
|
||||
.socketTimeoutMillis(redisTimeout)
|
||||
.ssl(sslEnabled)
|
||||
.build();
|
||||
}
|
||||
|
||||
// Create JedisPooled with configuration
|
||||
JedisPooled jedisPooled = new JedisPooled(poolConfig, hostAndPort, clientConfig);
|
||||
|
||||
// Test the connection
|
||||
try {
|
||||
jedisPooled.ping();
|
||||
log.info("Successfully connected to Redis at {}:{}", redisHost, redisPort);
|
||||
} catch (Exception pingException) {
|
||||
log.warn(
|
||||
"Redis ping failed at {}:{} - {}. Redis might be unavailable.",
|
||||
redisHost,
|
||||
redisPort,
|
||||
pingException.getMessage());
|
||||
// Close the pool if ping fails
|
||||
try {
|
||||
jedisPooled.close();
|
||||
} catch (Exception closeException) {
|
||||
// Ignore close exceptions
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return jedisPooled;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create JedisPooled connection", e);
|
||||
// Return null to fall back to SimpleVectorStore
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.vectorstore.SimpleVectorStore;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.ai.vectorstore.redis.RedisVectorStore;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import redis.clients.jedis.JedisPooled;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public class ChatbotVectorStoreConfig {
|
||||
|
||||
private static final String DEFAULT_INDEX = "stirling-chatbot-index";
|
||||
private static final String DEFAULT_PREFIX = "stirling:chatbot:";
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public VectorStore chatbotVectorStore(
|
||||
@Autowired(required = false) JedisPooled jedisPooled, EmbeddingModel embeddingModel) {
|
||||
if (jedisPooled != null) {
|
||||
try {
|
||||
log.info("Initialising Redis vector store for chatbot usage");
|
||||
return RedisVectorStore.builder(jedisPooled, embeddingModel)
|
||||
.indexName(DEFAULT_INDEX)
|
||||
.prefix(DEFAULT_PREFIX)
|
||||
.initializeSchema(true)
|
||||
.build();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn(
|
||||
"Redis vector store unavailable ({}). Falling back to SimpleVectorStore.",
|
||||
sanitize(ex.getMessage()));
|
||||
}
|
||||
} else {
|
||||
log.info("No Redis connection detected; using SimpleVectorStore for chatbot.");
|
||||
}
|
||||
|
||||
return SimpleVectorStore.builder(embeddingModel).build();
|
||||
}
|
||||
|
||||
private String sanitize(String message) {
|
||||
return message == null ? "unknown error" : message.replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Spring AI Configuration for Stirling PDF Chatbot
|
||||
*
|
||||
* <p>This configuration enables Spring AI auto-configuration for chatbot features. The actual
|
||||
* ChatModel and EmbeddingModel beans are provided by Spring Boot's auto-configuration based on the
|
||||
* spring.ai.* properties in application-proprietary.properties
|
||||
*
|
||||
* <p>For OpenAI: - spring.ai.openai.enabled=true - spring.ai.openai.api-key=your-api-key
|
||||
*
|
||||
* <p>For Ollama (as fallback): - spring.ai.ollama.enabled=true -
|
||||
* spring.ai.ollama.base-url=http://localhost:11434
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class SpringAIConfig {
|
||||
|
||||
public SpringAIConfig() {
|
||||
log.info("Spring AI Configuration enabled for Stirling PDF Chatbot");
|
||||
log.info(
|
||||
"ChatModel and EmbeddingModel beans will be auto-configured based on spring.ai.* properties");
|
||||
}
|
||||
|
||||
/** Primary ChatModel bean that delegates to OpenAI's auto-configured bean */
|
||||
@Bean
|
||||
@Primary
|
||||
public ChatModel primaryChatModel(@Qualifier("openAiChatModel") ChatModel openAiChatModel) {
|
||||
log.info("Using OpenAI ChatModel as primary");
|
||||
return openAiChatModel;
|
||||
}
|
||||
|
||||
/** Primary EmbeddingModel bean that delegates to OpenAI's auto-configured bean */
|
||||
@Bean
|
||||
@Primary
|
||||
public EmbeddingModel primaryEmbeddingModel(
|
||||
@Qualifier("openAiEmbeddingModel") EmbeddingModel openAiEmbeddingModel) {
|
||||
log.info("Using OpenAI EmbeddingModel as primary");
|
||||
return openAiEmbeddingModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom RestTemplate for Spring AI OpenAI client with increased timeouts. This helps prevent
|
||||
* timeout errors when processing large documents or complex queries.
|
||||
*/
|
||||
@Bean(name = "openAiRestTemplate")
|
||||
public RestTemplate openAiRestTemplate(RestTemplateBuilder builder) {
|
||||
log.info("Creating custom RestTemplate for OpenAI with 60s timeouts");
|
||||
return builder.connectTimeout(Duration.ofSeconds(60))
|
||||
.readTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom RestClient for Spring AI OpenAI with increased timeouts. Spring AI 1.0.3+ prefers
|
||||
* RestClient over RestTemplate.
|
||||
*/
|
||||
@Bean(name = "openAiRestClient")
|
||||
public RestClient openAiRestClient() {
|
||||
log.info("Creating custom RestClient for OpenAI with 60s timeouts");
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofSeconds(60));
|
||||
factory.setReadTimeout(Duration.ofSeconds(60));
|
||||
|
||||
return RestClient.builder().requestFactory(factory).build();
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package stirling.software.proprietary.controller;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSessionResponse;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotCacheService;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotService;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotSessionRegistry;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/internal/chatbot")
|
||||
public class ChatbotController {
|
||||
|
||||
private final ChatbotService chatbotService;
|
||||
private final ChatbotSessionRegistry sessionRegistry;
|
||||
private final ChatbotCacheService cacheService;
|
||||
private final ChatbotFeatureProperties featureProperties;
|
||||
|
||||
@PostMapping("/session")
|
||||
public ResponseEntity<ChatbotSessionResponse> createSession(
|
||||
@RequestBody ChatbotSessionCreateRequest request) {
|
||||
ChatbotSession session = chatbotService.createSession(request);
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
ChatbotSessionResponse response = toResponse(session, settings);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PostMapping("/query")
|
||||
public ResponseEntity<ChatbotResponse> query(@RequestBody ChatbotQueryRequest request) {
|
||||
ChatbotResponse response = chatbotService.ask(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/session/{sessionId}")
|
||||
public ResponseEntity<ChatbotSessionResponse> getSession(@PathVariable String sessionId) {
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
ChatbotSession session =
|
||||
sessionRegistry
|
||||
.findById(sessionId)
|
||||
.orElseThrow(() -> new ChatbotException("Session not found"));
|
||||
ChatbotSessionResponse response = toResponse(session, settings);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/document/{documentId}")
|
||||
public ResponseEntity<ChatbotSessionResponse> getSessionByDocument(
|
||||
@PathVariable String documentId) {
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
ChatbotSession session =
|
||||
sessionRegistry
|
||||
.findByDocumentId(documentId)
|
||||
.orElseThrow(() -> new ChatbotException("Session not found"));
|
||||
return ResponseEntity.ok(toResponse(session, settings));
|
||||
}
|
||||
|
||||
@DeleteMapping("/session/{sessionId}")
|
||||
public ResponseEntity<Void> closeSession(@PathVariable String sessionId) {
|
||||
chatbotService.close(sessionId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
if (session != null && session.isImageContentDetected()) {
|
||||
warnings.add("Images detected - Images are not currently supported.");
|
||||
}
|
||||
|
||||
warnings.add("Images are not yet supported. Only extracted text is sent for analysis.");
|
||||
if (session != null && session.isOcrRequested()) {
|
||||
warnings.add("OCR requested – uses credits .");
|
||||
}
|
||||
|
||||
if (session != null && session.getUsageSummary() != null) {
|
||||
ChatbotUsageSummary usage = session.getUsageSummary();
|
||||
if (usage.isLimitExceeded()) {
|
||||
warnings.add("Monthly chatbot allocation exceeded – requests may be throttled.");
|
||||
} else if (usage.isNearingLimit()) {
|
||||
warnings.add("You are approaching the monthly chatbot allocation.");
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private ChatbotSessionResponse toResponse(ChatbotSession session, ChatbotSettings settings) {
|
||||
return ChatbotSessionResponse.builder()
|
||||
.sessionId(session.getSessionId())
|
||||
.documentId(session.getDocumentId())
|
||||
.alphaWarning(settings.alphaWarning())
|
||||
.ocrRequested(session.isOcrRequested())
|
||||
.imageContentDetected(session.isImageContentDetected())
|
||||
.textCharacters(session.getTextCharacters())
|
||||
.estimatedTokens(session.getEstimatedTokens())
|
||||
.maxCachedCharacters(cacheService.getMaxDocumentCharacters())
|
||||
.createdAt(session.getCreatedAt())
|
||||
.warnings(sessionWarnings(settings, session))
|
||||
.metadata(session.getMetadata())
|
||||
.usageSummary(session.getUsageSummary())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jetty.client.HttpResponseException;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotService;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = ChatbotController.class)
|
||||
@Slf4j
|
||||
// @ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
|
||||
@ConditionalOnBean(ChatbotService.class)
|
||||
public class ChatbotExceptionHandler {
|
||||
|
||||
@ExceptionHandler(NoTextDetectedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleNoText(NoTextDetectedException ex) {
|
||||
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ChatbotException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleChatbot(ChatbotException ex) {
|
||||
log.debug("Chatbot exception: {}", ex.getMessage());
|
||||
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
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) {
|
||||
Map<String, Object> payload =
|
||||
Map.of(
|
||||
"timestamp", Instant.now().toString(),
|
||||
"status", status.value(),
|
||||
"error", message);
|
||||
return ResponseEntity.status(status).body(payload);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotDocumentCacheEntry {
|
||||
|
||||
private String cacheKey;
|
||||
private String sessionId;
|
||||
private String documentId;
|
||||
private Map<String, String> metadata;
|
||||
private boolean ocrApplied;
|
||||
private boolean imageContentDetected;
|
||||
private long textCharacters;
|
||||
private Instant storedAt;
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Simple record representing a stored chatbot conversation turn. */
|
||||
public record ChatbotHistoryEntry(
|
||||
String role, String content, String documentId, String documentName, Instant timestamp) {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotQueryRequest {
|
||||
|
||||
private String sessionId;
|
||||
private String prompt;
|
||||
private boolean allowEscalation;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotResponse {
|
||||
|
||||
private String sessionId;
|
||||
private String modelUsed;
|
||||
private double confidence;
|
||||
private String answer;
|
||||
private boolean escalated;
|
||||
private boolean servedFromNanoOnly;
|
||||
private boolean cacheHit;
|
||||
private Instant respondedAt;
|
||||
private List<String> warnings;
|
||||
private Map<String, Object> metadata;
|
||||
private long promptTokens;
|
||||
private long completionTokens;
|
||||
private long totalTokens;
|
||||
private ChatbotUsageSummary usageSummary;
|
||||
|
||||
public List<String> getWarnings() {
|
||||
return warnings == null ? Collections.emptyList() : warnings;
|
||||
}
|
||||
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class ChatbotSession {
|
||||
|
||||
private String sessionId;
|
||||
private String documentId;
|
||||
private String userId;
|
||||
private Map<String, String> metadata;
|
||||
private boolean ocrRequested;
|
||||
private boolean warningsAccepted;
|
||||
private boolean alphaWarningRequired;
|
||||
private boolean imageContentDetected;
|
||||
private long textCharacters;
|
||||
private long estimatedTokens;
|
||||
private String cacheKey;
|
||||
private String vectorStoreId;
|
||||
private Instant createdAt;
|
||||
private ChatbotUsageSummary usageSummary;
|
||||
|
||||
public static String randomSessionId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotSessionCreateRequest {
|
||||
|
||||
private String sessionId;
|
||||
private String documentId;
|
||||
private String userId;
|
||||
private String text;
|
||||
private Map<String, String> metadata;
|
||||
private boolean ocrRequested;
|
||||
private boolean warningsAccepted;
|
||||
private boolean imagesDetected;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotSessionResponse {
|
||||
|
||||
private String sessionId;
|
||||
private String documentId;
|
||||
private boolean alphaWarning;
|
||||
private boolean ocrRequested;
|
||||
private boolean imageContentDetected;
|
||||
private long maxCachedCharacters;
|
||||
private long textCharacters;
|
||||
private long estimatedTokens;
|
||||
private Instant createdAt;
|
||||
private List<String> warnings;
|
||||
private Map<String, String> metadata;
|
||||
private ChatbotUsageSummary usageSummary;
|
||||
|
||||
public List<String> getWarnings() {
|
||||
return warnings == null ? Collections.emptyList() : warnings;
|
||||
}
|
||||
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata == null ? Collections.emptyMap() : metadata;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.model.chatbot;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatbotUsageSummary {
|
||||
|
||||
private long allocatedTokens;
|
||||
private long consumedTokens;
|
||||
private long remainingTokens;
|
||||
private double usageRatio;
|
||||
private boolean nearingLimit;
|
||||
private boolean limitExceeded;
|
||||
private long lastIncrementTokens;
|
||||
private String window;
|
||||
}
|
||||
+17
-7
@@ -59,6 +59,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -84,8 +85,7 @@ public class SecurityConfiguration {
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -106,8 +106,7 @@ public class SecurityConfiguration {
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
UserLicenseSettingsService licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
@@ -221,9 +220,19 @@ public class SecurityConfiguration {
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
String trimmedUri =
|
||||
uri.startsWith(contextPath)
|
||||
? uri.substring(
|
||||
contextPath.length())
|
||||
: uri;
|
||||
|
||||
// Ignore CSRF for auth endpoints
|
||||
if (uri.startsWith("/api/v1/auth/")) {
|
||||
// Ignore CSRF for auth endpoints + oauth/saml
|
||||
if (trimmedUri.startsWith("/api/v1/auth/")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.startsWith(
|
||||
"/login/oauth2/code/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -360,7 +369,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
|
||||
+6
-1
@@ -283,7 +283,12 @@ public class AdminLicenseController {
|
||||
// Prevent path traversal and enforce single filename component
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Filename must not contain path separators or '..'"));
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
|
||||
+3
-2
@@ -38,6 +38,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class CustomOAuth2AuthenticationSuccessHandler
|
||||
@@ -50,8 +51,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
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;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
|
||||
|
||||
@Service
|
||||
// @ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public class ChatbotCacheService {
|
||||
|
||||
private final Cache<String, ChatbotDocumentCacheEntry>
|
||||
documentCache; // todo: can redis be used instead?
|
||||
private final long maxDocumentCharacters;
|
||||
private final Map<String, String> sessionToCacheKey = new ConcurrentHashMap<>();
|
||||
|
||||
public ChatbotCacheService(ApplicationProperties applicationProperties) {
|
||||
Chatbot chatbotConfig = resolveChatbot(applicationProperties);
|
||||
ApplicationProperties.Premium.ProFeatures.Chatbot.Cache cacheSettings =
|
||||
chatbotConfig.getCache();
|
||||
this.maxDocumentCharacters = cacheSettings.getMaxDocumentCharacters();
|
||||
long ttlMinutes = Math.max(cacheSettings.getTtlMinutes(), 1);
|
||||
long maxEntries = Math.max(cacheSettings.getMaxEntries(), 1);
|
||||
long maxTotalCharacters =
|
||||
Math.max(cacheSettings.getMaxDocumentCharacters() * maxEntries, 1);
|
||||
|
||||
this.documentCache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumWeight(maxTotalCharacters)
|
||||
.weigher(
|
||||
(String key, ChatbotDocumentCacheEntry entry) ->
|
||||
(int)
|
||||
Math.min(
|
||||
entry.getTextCharacters()
|
||||
+ estimateMetadataWeight(entry),
|
||||
Integer.MAX_VALUE))
|
||||
.expireAfterWrite(Duration.ofMinutes(ttlMinutes))
|
||||
.recordStats()
|
||||
.build();
|
||||
log.info(
|
||||
"Initialised chatbot document cache with maxEntries={} ttlMinutes={} maxChars={} maxWeight={} characters",
|
||||
maxEntries,
|
||||
ttlMinutes,
|
||||
maxDocumentCharacters,
|
||||
maxTotalCharacters);
|
||||
}
|
||||
|
||||
public long getMaxDocumentCharacters() {
|
||||
return maxDocumentCharacters;
|
||||
}
|
||||
|
||||
private long estimateMetadataWeight(ChatbotDocumentCacheEntry entry) {
|
||||
if (entry == null || entry.getMetadata() == null) {
|
||||
return 0L;
|
||||
}
|
||||
return entry.getMetadata().entrySet().stream()
|
||||
.mapToLong(e -> safeLength(e.getKey()) + safeLength(e.getValue()))
|
||||
.sum();
|
||||
}
|
||||
|
||||
private long safeLength(String value) {
|
||||
return value == null ? 0L : value.length();
|
||||
}
|
||||
|
||||
public String register(
|
||||
String sessionId,
|
||||
String documentId,
|
||||
Map<String, String> metadata,
|
||||
boolean ocrApplied,
|
||||
boolean imageContentDetected,
|
||||
long textCharacters) {
|
||||
Objects.requireNonNull(sessionId, "sessionId must not be null");
|
||||
Objects.requireNonNull(documentId, "documentId must not be null");
|
||||
String cacheKey =
|
||||
sessionToCacheKey.computeIfAbsent(sessionId, k -> UUID.randomUUID().toString());
|
||||
ChatbotDocumentCacheEntry entry =
|
||||
ChatbotDocumentCacheEntry.builder()
|
||||
.cacheKey(cacheKey)
|
||||
.sessionId(sessionId)
|
||||
.documentId(documentId)
|
||||
.metadata(metadata)
|
||||
.ocrApplied(ocrApplied)
|
||||
.imageContentDetected(imageContentDetected)
|
||||
.textCharacters(textCharacters)
|
||||
.storedAt(Instant.now())
|
||||
.build();
|
||||
documentCache.put(cacheKey, entry);
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
public Optional<ChatbotDocumentCacheEntry> resolveByCacheKey(String cacheKey) {
|
||||
return Optional.ofNullable(documentCache.getIfPresent(cacheKey));
|
||||
}
|
||||
|
||||
public Optional<ChatbotDocumentCacheEntry> resolveBySessionId(String sessionId) {
|
||||
return Optional.ofNullable(sessionToCacheKey.get(sessionId))
|
||||
.flatMap(this::resolveByCacheKey);
|
||||
}
|
||||
|
||||
public void invalidateSession(String sessionId) {
|
||||
Optional.ofNullable(sessionToCacheKey.remove(sessionId))
|
||||
.ifPresent(documentCache::invalidate);
|
||||
}
|
||||
|
||||
public void invalidateCacheKey(String cacheKey) {
|
||||
documentCache.invalidate(cacheKey);
|
||||
sessionToCacheKey.values().removeIf(value -> value.equals(cacheKey));
|
||||
}
|
||||
|
||||
public Map<String, ChatbotDocumentCacheEntry> snapshot() {
|
||||
return Map.copyOf(documentCache.asMap());
|
||||
}
|
||||
|
||||
private Chatbot resolveChatbot(ApplicationProperties properties) {
|
||||
if (properties == null) {
|
||||
return new Chatbot();
|
||||
}
|
||||
Premium premium = properties.getPremium();
|
||||
if (premium == null) {
|
||||
return new Chatbot();
|
||||
}
|
||||
ProFeatures pro = premium.getProFeatures();
|
||||
if (pro == null) {
|
||||
return new Chatbot();
|
||||
}
|
||||
Chatbot chatbot = pro.getChatbot();
|
||||
return chatbot == null ? new Chatbot() : chatbot;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
@Component
|
||||
public class ChatbotContextCompressor {
|
||||
|
||||
private static final int DEFAULT_SUMMARY_LIMIT = 3000;
|
||||
private static final int MIN_CHUNK_SNIPPET = 160;
|
||||
|
||||
public String summarize(List<Document> documents, int requestedLimit) {
|
||||
if (CollectionUtils.isEmpty(documents)) {
|
||||
return "No contextual snippets available for this session.";
|
||||
}
|
||||
int maxChars =
|
||||
requestedLimit > 0
|
||||
? Math.min(requestedLimit, DEFAULT_SUMMARY_LIMIT)
|
||||
: DEFAULT_SUMMARY_LIMIT;
|
||||
StringBuilder builder = new StringBuilder();
|
||||
int perChunkLimit = Math.max(MIN_CHUNK_SNIPPET, maxChars / Math.max(documents.size(), 1));
|
||||
for (Document doc : documents) {
|
||||
if (builder.length() >= maxChars) {
|
||||
break;
|
||||
}
|
||||
String chunkOrder = doc.getMetadata().getOrDefault("chunkOrder", "?").toString();
|
||||
String text = trimContent(doc.getText(), perChunkLimit);
|
||||
builder.append("Chunk ").append(chunkOrder).append(": ").append(text).append('\n');
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return "Unable to summarise context; original content unavailable.";
|
||||
}
|
||||
return builder.substring(0, Math.min(builder.length(), maxChars)).trim();
|
||||
}
|
||||
|
||||
private String trimContent(String content, int perChunkLimit) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return "(empty chunk)";
|
||||
}
|
||||
String normalized = content.replaceAll("\\s+", " ").trim();
|
||||
if (normalized.length() <= perChunkLimit) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(0, Math.max(0, perChunkLimit - 3)) + "...";
|
||||
}
|
||||
}
|
||||
+633
@@ -0,0 +1,633 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.ollama.OllamaChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChatbotConversationService {
|
||||
|
||||
private static final int SUMMARY_TRIGGER_MULTIPLIER = 3;
|
||||
private static final int SUMMARY_TRANSCRIPT_MAX_CHARS = 4000;
|
||||
|
||||
private final ChatModel chatModel;
|
||||
private final ChatbotSessionRegistry sessionRegistry;
|
||||
private final ChatbotCacheService cacheService;
|
||||
private final ChatbotFeatureProperties featureProperties;
|
||||
private final ChatbotRetrievalService retrievalService;
|
||||
private final ChatbotContextCompressor contextCompressor;
|
||||
private final ChatbotMemoryService memoryService;
|
||||
private final ChatbotUsageService usageService;
|
||||
private final ChatbotConversationStore conversationStore;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AtomicBoolean modelSwitchVerified = new AtomicBoolean(false);
|
||||
|
||||
public ChatbotResponse handleQuery(ChatbotQueryRequest request) {
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
if (!settings.enabled()) {
|
||||
throw new ChatbotException("Chatbot feature is disabled");
|
||||
}
|
||||
if (!StringUtils.hasText(request.getPrompt())) {
|
||||
throw new ChatbotException("Prompt cannot be empty");
|
||||
}
|
||||
if (request.getPrompt().length() > settings.maxPromptCharacters()) {
|
||||
throw new ChatbotException("Prompt exceeds maximum allowed characters");
|
||||
}
|
||||
ChatbotSession session =
|
||||
sessionRegistry
|
||||
.findById(request.getSessionId())
|
||||
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
|
||||
|
||||
ensureModelSwitchCapability(settings);
|
||||
|
||||
ChatbotDocumentCacheEntry cacheEntry =
|
||||
cacheService
|
||||
.resolveBySessionId(request.getSessionId())
|
||||
.orElseThrow(() -> new ChatbotException("Session cache not found"));
|
||||
|
||||
List<String> warnings = buildWarnings(settings, session);
|
||||
|
||||
List<Document> context =
|
||||
retrievalService.retrieveTopK(
|
||||
request.getSessionId(), request.getPrompt(), settings);
|
||||
String contextSummary =
|
||||
contextCompressor.summarize(
|
||||
context, (int) Math.max(settings.maxPromptCharacters() / 2, 1000));
|
||||
List<ChatbotHistoryEntry> conversationHistory =
|
||||
loadConversationHistory(session.getSessionId());
|
||||
String conversationSummary = loadConversationSummary(session.getSessionId());
|
||||
|
||||
ModelReply nanoReply =
|
||||
invokeModel(
|
||||
settings,
|
||||
settings.models().primary(),
|
||||
request.getPrompt(),
|
||||
session,
|
||||
context,
|
||||
contextSummary,
|
||||
cacheEntry.getMetadata(),
|
||||
conversationHistory,
|
||||
conversationSummary);
|
||||
|
||||
boolean shouldEscalate =
|
||||
request.isAllowEscalation()
|
||||
&& (nanoReply.requiresEscalation()
|
||||
|| nanoReply.confidence() < settings.minConfidenceNano()
|
||||
|| request.getPrompt().length() > settings.maxPromptCharacters());
|
||||
|
||||
ModelReply finalReply = nanoReply;
|
||||
boolean escalated = false;
|
||||
if (shouldEscalate) {
|
||||
escalated = true;
|
||||
finalReply =
|
||||
invokeModel(
|
||||
settings,
|
||||
settings.models().fallback(),
|
||||
request.getPrompt(),
|
||||
session,
|
||||
context,
|
||||
contextSummary,
|
||||
cacheEntry.getMetadata(),
|
||||
conversationHistory,
|
||||
conversationSummary);
|
||||
}
|
||||
|
||||
ChatbotUsageSummary usageSummary =
|
||||
usageService.registerGeneration(
|
||||
session.getUserId(),
|
||||
finalReply.promptTokens(),
|
||||
finalReply.completionTokens());
|
||||
session.setUsageSummary(usageSummary);
|
||||
|
||||
memoryService.recordTurn(session, request.getPrompt(), finalReply.answer());
|
||||
recordHistoryTurn(session, "user", request.getPrompt());
|
||||
recordHistoryTurn(session, "assistant", finalReply.answer());
|
||||
summarizeConversation(settings, session);
|
||||
enforceHistoryRetention(session);
|
||||
|
||||
return ChatbotResponse.builder()
|
||||
.sessionId(request.getSessionId())
|
||||
.modelUsed(
|
||||
shouldEscalate ? settings.models().fallback() : settings.models().primary())
|
||||
.confidence(finalReply.confidence())
|
||||
.answer(finalReply.answer())
|
||||
.escalated(escalated)
|
||||
.servedFromNanoOnly(!escalated)
|
||||
.cacheHit(true)
|
||||
.respondedAt(Instant.now())
|
||||
.warnings(warnings)
|
||||
.metadata(buildMetadata(settings, session, finalReply, context.size(), escalated))
|
||||
.promptTokens(finalReply.promptTokens())
|
||||
.completionTokens(finalReply.completionTokens())
|
||||
.totalTokens(finalReply.totalTokens())
|
||||
.usageSummary(usageSummary)
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<String> buildWarnings(ChatbotSettings settings, ChatbotSession session) {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
warnings.add("Chatbot is in alpha – behaviour may change.");
|
||||
|
||||
if (session.isImageContentDetected()) {
|
||||
warnings.add("Image content is not yet supported.");
|
||||
}
|
||||
if (session.isOcrRequested()) {
|
||||
warnings.add("OCR costs may apply for this session.");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMetadata(
|
||||
ChatbotSettings settings,
|
||||
ChatbotSession session,
|
||||
ModelReply reply,
|
||||
int contextSize,
|
||||
boolean escalated) {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("contextSize", contextSize);
|
||||
metadata.put("requiresEscalation", reply.requiresEscalation());
|
||||
metadata.put("escalated", escalated);
|
||||
metadata.put("rationale", reply.rationale());
|
||||
metadata.put("modelProvider", settings.models().provider().name());
|
||||
metadata.put("imageContentDetected", session.isImageContentDetected());
|
||||
metadata.put("charactersCached", session.getTextCharacters());
|
||||
metadata.put("promptTokens", reply.promptTokens());
|
||||
metadata.put("completionTokens", reply.completionTokens());
|
||||
metadata.put("totalTokens", reply.totalTokens());
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private void ensureModelSwitchCapability(ChatbotSettings settings) {
|
||||
ChatbotSettings.ModelProvider provider = settings.models().provider();
|
||||
|
||||
switch (provider) {
|
||||
case OPENAI -> {
|
||||
if (!(chatModel instanceof OpenAiChatModel)) {
|
||||
throw new ChatbotException(
|
||||
"Chatbot requires an OpenAI chat model to support runtime model switching.");
|
||||
}
|
||||
}
|
||||
case OLLAMA -> {
|
||||
if (!(chatModel instanceof OllamaChatModel)) {
|
||||
throw new ChatbotException(
|
||||
"Chatbot is configured for Ollama but no Ollama chat model bean is available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modelSwitchVerified.compareAndSet(false, true)) {
|
||||
log.info(
|
||||
"Verified runtime model override support for provider {} ({} -> {})",
|
||||
provider,
|
||||
settings.models().primary(),
|
||||
settings.models().fallback());
|
||||
}
|
||||
}
|
||||
|
||||
private ModelReply invokeModel(
|
||||
ChatbotSettings settings,
|
||||
String model,
|
||||
String prompt,
|
||||
ChatbotSession session,
|
||||
List<Document> context,
|
||||
String contextSummary,
|
||||
Map<String, String> metadata,
|
||||
List<ChatbotHistoryEntry> history,
|
||||
String conversationSummary) {
|
||||
Prompt requestPrompt =
|
||||
buildPrompt(
|
||||
settings,
|
||||
model,
|
||||
prompt,
|
||||
session,
|
||||
context,
|
||||
contextSummary,
|
||||
metadata,
|
||||
history,
|
||||
conversationSummary);
|
||||
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);
|
||||
}
|
||||
long promptTokens = 0L;
|
||||
long completionTokens = 0L;
|
||||
long totalTokens = 0L;
|
||||
|
||||
if (response != null && response.getMetadata() != null) {
|
||||
Usage usage = response.getMetadata().getUsage();
|
||||
|
||||
if (usage != null) {
|
||||
promptTokens = toLong(usage.getPromptTokens());
|
||||
completionTokens = toLong(usage.getCompletionTokens());
|
||||
totalTokens =
|
||||
usage.getTotalTokens() != null
|
||||
? usage.getTotalTokens()
|
||||
: promptTokens + completionTokens;
|
||||
}
|
||||
}
|
||||
String content =
|
||||
Optional.ofNullable(response)
|
||||
.map(ChatResponse::getResults)
|
||||
.filter(results -> !results.isEmpty())
|
||||
.map(results -> results.get(0).getOutput().getText())
|
||||
.orElse("");
|
||||
return parseModelResponse(content, promptTokens, completionTokens, totalTokens);
|
||||
}
|
||||
|
||||
private Prompt buildPrompt(
|
||||
ChatbotSettings settings,
|
||||
String model,
|
||||
String question,
|
||||
ChatbotSession session,
|
||||
List<Document> context,
|
||||
String contextSummary,
|
||||
Map<String, String> metadata,
|
||||
List<ChatbotHistoryEntry> history,
|
||||
String conversationSummary) {
|
||||
String chunkOutline = buildChunkOutline(context);
|
||||
String chunkExcerpts = buildChunkExcerpts(context);
|
||||
String metadataSummary =
|
||||
metadata.entrySet().stream()
|
||||
.map(entry -> entry.getKey() + ": " + entry.getValue())
|
||||
.reduce((left, right) -> left + ", " + right)
|
||||
.orElse("none");
|
||||
String recentTurns = buildConversationOutline(history);
|
||||
|
||||
String imageDirective =
|
||||
session.isImageContentDetected()
|
||||
? "Images were detected in this PDF. You must explain that image analysis is not available."
|
||||
: "No images detected in this PDF.";
|
||||
|
||||
String systemPrompt =
|
||||
"You are Stirling PDF Bot. Use provided context strictly. "
|
||||
+ "Respond in compact JSON with fields answer (string), confidence (0..1), requiresEscalation (boolean), rationale (string). "
|
||||
+ "Explain limitations when context insufficient. Always note that image analysis is not supported yet.";
|
||||
|
||||
String userPrompt =
|
||||
"Document metadata: "
|
||||
+ metadataSummary
|
||||
+ "\nOCR applied: "
|
||||
+ session.isOcrRequested()
|
||||
+ "\n"
|
||||
+ imageDirective
|
||||
+ "\nConversation summary:\n"
|
||||
+ (StringUtils.hasText(conversationSummary)
|
||||
? conversationSummary
|
||||
: "No persistent summary available.")
|
||||
+ "\nRecent conversation turns:\n"
|
||||
+ recentTurns
|
||||
+ "\nContext summary:\n"
|
||||
+ contextSummary
|
||||
+ "\nContext outline:\n"
|
||||
+ chunkOutline
|
||||
+ "\nSelected excerpts:\n"
|
||||
+ chunkExcerpts
|
||||
+ "Question: "
|
||||
+ question;
|
||||
|
||||
OpenAiChatOptions options = buildChatOptions(settings, model);
|
||||
|
||||
return new Prompt(
|
||||
List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options);
|
||||
}
|
||||
|
||||
private OpenAiChatOptions buildChatOptions(ChatbotSettings settings, String model) {
|
||||
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder().model(model);
|
||||
String normalizedModel = model == null ? "" : model.toLowerCase();
|
||||
boolean reasoningModel = normalizedModel.startsWith("gpt-5-");
|
||||
if (!reasoningModel) {
|
||||
builder.topP(settings.models().topP());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private String buildChunkOutline(List<Document> context) {
|
||||
if (context == null || context.isEmpty()) {
|
||||
return "No chunks retrieved for this question.";
|
||||
}
|
||||
StringBuilder outline = new StringBuilder();
|
||||
for (Document chunk : context) {
|
||||
String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString();
|
||||
String snippet = chunk.getText();
|
||||
if (snippet != null) {
|
||||
snippet = snippet.replaceAll("\\s+", " ").trim();
|
||||
if (snippet.length() > 240) {
|
||||
snippet = snippet.substring(0, 237) + "...";
|
||||
}
|
||||
} else {
|
||||
snippet = "(empty)";
|
||||
}
|
||||
outline.append("- Chunk ").append(order).append(": ").append(snippet).append("\n");
|
||||
}
|
||||
return outline.toString();
|
||||
}
|
||||
|
||||
private String buildChunkExcerpts(List<Document> context) {
|
||||
if (context == null || context.isEmpty()) {
|
||||
return "No excerpts available.";
|
||||
}
|
||||
StringBuilder excerpts = new StringBuilder();
|
||||
for (Document chunk : context) {
|
||||
String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString();
|
||||
String snippet = chunk.getText();
|
||||
if (!StringUtils.hasText(snippet)) {
|
||||
continue;
|
||||
}
|
||||
String normalized = snippet.replaceAll("\\s+", " ").trim();
|
||||
int maxExcerpt = 400;
|
||||
if (normalized.length() > maxExcerpt) {
|
||||
normalized = normalized.substring(0, maxExcerpt - 3) + "...";
|
||||
}
|
||||
excerpts.append("[Chunk ").append(order).append("] ").append(normalized).append("\n");
|
||||
}
|
||||
if (!StringUtils.hasText(excerpts)) {
|
||||
return "Chunks retrieved but no text excerpts available.";
|
||||
}
|
||||
return excerpts.toString();
|
||||
}
|
||||
|
||||
private String buildConversationOutline(List<ChatbotHistoryEntry> history) {
|
||||
if (history == null || history.isEmpty()) {
|
||||
return "No earlier turns stored for this session.";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (ChatbotHistoryEntry entry : history) {
|
||||
if (entry == null || !StringUtils.hasText(entry.content())) {
|
||||
continue;
|
||||
}
|
||||
builder.append(entry.role()).append(": ").append(entry.content().trim());
|
||||
if (StringUtils.hasText(entry.documentName())) {
|
||||
builder.append(" (doc: ").append(entry.documentName()).append(")");
|
||||
}
|
||||
builder.append("\n");
|
||||
}
|
||||
if (!StringUtils.hasText(builder)) {
|
||||
return "Conversation history available but empty after filtering.";
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private ModelReply parseModelResponse(
|
||||
String raw, long promptTokens, long completionTokens, long totalTokens) {
|
||||
if (!StringUtils.hasText(raw)) {
|
||||
throw new ChatbotException("Model returned empty response");
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(raw);
|
||||
String answer =
|
||||
Optional.ofNullable(node.get("answer")).map(JsonNode::asText).orElse(raw);
|
||||
double confidence =
|
||||
Optional.ofNullable(node.get("confidence"))
|
||||
.map(JsonNode::asDouble)
|
||||
.orElse(0.0D);
|
||||
boolean requiresEscalation =
|
||||
Optional.ofNullable(node.get("requiresEscalation"))
|
||||
.map(JsonNode::asBoolean)
|
||||
.orElse(false);
|
||||
String rationale =
|
||||
Optional.ofNullable(node.get("rationale"))
|
||||
.map(JsonNode::asText)
|
||||
.orElse("Model did not provide rationale");
|
||||
return new ModelReply(
|
||||
answer,
|
||||
confidence,
|
||||
requiresEscalation,
|
||||
rationale,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens);
|
||||
} catch (IOException ex) {
|
||||
log.warn("Failed to parse model JSON response, returning raw text", ex);
|
||||
return new ModelReply(
|
||||
raw,
|
||||
0.0D,
|
||||
true,
|
||||
"Unable to parse JSON response",
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens);
|
||||
}
|
||||
}
|
||||
|
||||
private record ModelReply(
|
||||
String answer,
|
||||
double confidence,
|
||||
boolean requiresEscalation,
|
||||
String rationale,
|
||||
long promptTokens,
|
||||
long completionTokens,
|
||||
long totalTokens) {}
|
||||
|
||||
private String sanitizeRemoteMessage(String message) {
|
||||
if (!StringUtils.hasText(message)) {
|
||||
return "unexpected provider error";
|
||||
}
|
||||
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
|
||||
}
|
||||
|
||||
private long toLong(Integer value) {
|
||||
return value == null ? 0L : value.longValue();
|
||||
}
|
||||
|
||||
private List<ChatbotHistoryEntry> loadConversationHistory(String sessionId) {
|
||||
if (conversationStore == null || !StringUtils.hasText(sessionId)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return conversationStore.getRecentTurns(sessionId, conversationStore.defaultWindow());
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Conversation history unavailable: {}", ex.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String loadConversationSummary(String sessionId) {
|
||||
if (conversationStore == null || !StringUtils.hasText(sessionId)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return conversationStore.loadSummary(sessionId);
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Conversation summary unavailable: {}", ex.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void recordHistoryTurn(ChatbotSession session, String role, String content) {
|
||||
if (conversationStore == null
|
||||
|| session == null
|
||||
|| !StringUtils.hasText(session.getSessionId())
|
||||
|| !StringUtils.hasText(content)) {
|
||||
return;
|
||||
}
|
||||
String documentName =
|
||||
Optional.ofNullable(session.getMetadata())
|
||||
.map(meta -> meta.getOrDefault("documentName", ""))
|
||||
.orElse("");
|
||||
ChatbotHistoryEntry entry =
|
||||
conversationStore.createEntry(role, content, session.getDocumentId(), documentName);
|
||||
try {
|
||||
conversationStore.appendTurn(session.getSessionId(), entry);
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Failed to persist chatbot conversation turn: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void summarizeConversation(ChatbotSettings settings, ChatbotSession session) {
|
||||
if (conversationStore == null
|
||||
|| session == null
|
||||
|| !StringUtils.hasText(session.getSessionId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
int window = conversationStore.defaultWindow();
|
||||
long historySize = conversationStore.historyLength(session.getSessionId());
|
||||
if (historySize < Math.max(window * SUMMARY_TRIGGER_MULTIPLIER, window + 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ChatbotHistoryEntry> entries =
|
||||
conversationStore.getRecentTurns(
|
||||
session.getSessionId(), conversationStore.retentionWindow());
|
||||
if (entries.isEmpty() || entries.size() <= window) {
|
||||
return;
|
||||
}
|
||||
|
||||
int cutoff = entries.size() - window;
|
||||
List<ChatbotHistoryEntry> summarizable = entries.subList(0, cutoff);
|
||||
String existingSummary = loadConversationSummary(session.getSessionId());
|
||||
String updatedSummary = summarizeHistory(settings, session, summarizable, existingSummary);
|
||||
if (StringUtils.hasText(updatedSummary)) {
|
||||
try {
|
||||
conversationStore.storeSummary(session.getSessionId(), updatedSummary);
|
||||
conversationStore.trimHistory(session.getSessionId(), window);
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Failed to persist chatbot summary: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String summarizeHistory(
|
||||
ChatbotSettings settings,
|
||||
ChatbotSession session,
|
||||
List<ChatbotHistoryEntry> entries,
|
||||
String existingSummary) {
|
||||
if (entries == null || entries.isEmpty()) {
|
||||
return existingSummary;
|
||||
}
|
||||
String priorSummary =
|
||||
StringUtils.hasText(existingSummary)
|
||||
? existingSummary
|
||||
: "No previous summary available.";
|
||||
String transcript = buildSummaryTranscript(entries);
|
||||
if (!StringUtils.hasText(transcript)) {
|
||||
return existingSummary;
|
||||
}
|
||||
String systemPrompt =
|
||||
"You maintain a concise running summary of Stirling PDF Bot conversations. "
|
||||
+ "Capture user goals, referenced documents, and key conclusions in under 200 words.";
|
||||
String userPrompt =
|
||||
"Existing summary:\n"
|
||||
+ priorSummary
|
||||
+ "\n\nNew conversation turns:\n"
|
||||
+ transcript
|
||||
+ "\n\nRespond with the updated summary only.";
|
||||
Prompt prompt =
|
||||
new Prompt(
|
||||
List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)),
|
||||
buildChatOptions(settings, settings.models().primary()));
|
||||
try {
|
||||
ChatResponse response = chatModel.call(prompt);
|
||||
return Optional.ofNullable(response)
|
||||
.map(ChatResponse::getResults)
|
||||
.filter(results -> !results.isEmpty())
|
||||
.map(results -> results.get(0).getOutput().getText())
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::hasText)
|
||||
.orElse(existingSummary);
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Conversation summarisation failed: {}", ex.getMessage());
|
||||
return existingSummary;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSummaryTranscript(List<ChatbotHistoryEntry> entries) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (ChatbotHistoryEntry entry : entries) {
|
||||
if (entry == null || !StringUtils.hasText(entry.content())) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() >= SUMMARY_TRANSCRIPT_MAX_CHARS) {
|
||||
builder.append("\n[conversation truncated]");
|
||||
break;
|
||||
}
|
||||
builder.append(entry.role()).append(": ").append(entry.content().trim());
|
||||
if (StringUtils.hasText(entry.documentName())) {
|
||||
builder.append(" (doc: ").append(entry.documentName()).append(")");
|
||||
}
|
||||
builder.append("\n");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void enforceHistoryRetention(ChatbotSession session) {
|
||||
if (conversationStore == null
|
||||
|| session == null
|
||||
|| !StringUtils.hasText(session.getSessionId())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
conversationStore.trimHistory(
|
||||
session.getSessionId(), conversationStore.retentionWindow());
|
||||
} catch (RuntimeException ex) {
|
||||
log.debug("Failed to enforce chatbot history retention: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
|
||||
|
||||
import redis.clients.jedis.JedisPooled;
|
||||
|
||||
/**
|
||||
* Lightweight Redis-backed conversation store that keeps a short rolling window and summary for
|
||||
* each chatbot session. This lays the groundwork for richer memory handling without yet impacting
|
||||
* the main conversation flow.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ChatbotConversationStore {
|
||||
|
||||
private static final String HISTORY_KEY = "chatbot:sessions:%s:history";
|
||||
private static final String SUMMARY_KEY = "chatbot:sessions:%s:summary";
|
||||
private static final Duration DEFAULT_TTL = Duration.ofHours(24);
|
||||
private static final int DEFAULT_WINDOW = 10;
|
||||
private static final int RETENTION_MULTIPLIER = 5;
|
||||
private static final int RETENTION_WINDOW = DEFAULT_WINDOW * RETENTION_MULTIPLIER;
|
||||
|
||||
private final JedisPooled jedis;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ChatbotConversationStore(
|
||||
ObjectProvider<JedisPooled> jedisProvider, ObjectMapper objectMapper) {
|
||||
this.jedis = jedisProvider.getIfAvailable();
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void appendTurn(String sessionId, ChatbotHistoryEntry entry) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId) || entry == null) {
|
||||
return;
|
||||
}
|
||||
execute(
|
||||
() -> {
|
||||
try {
|
||||
String payload = objectMapper.writeValueAsString(entry);
|
||||
String key = historyKey(sessionId);
|
||||
jedis.rpush(key, payload);
|
||||
jedis.expire(key, (int) DEFAULT_TTL.getSeconds());
|
||||
jedis.expire(summaryKey(sessionId), (int) DEFAULT_TTL.getSeconds());
|
||||
} catch (JsonProcessingException ex) {
|
||||
log.debug("Failed to serialise chatbot turn", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<ChatbotHistoryEntry> getRecentTurns(String sessionId, int limit) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return execute(
|
||||
() -> {
|
||||
String key = historyKey(sessionId);
|
||||
long size = jedis.llen(key);
|
||||
if (size <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
long start = Math.max(0, size - Math.max(limit, 1));
|
||||
List<String> raw = jedis.lrange(key, start, size);
|
||||
if (CollectionUtils.isEmpty(raw)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ChatbotHistoryEntry> entries = new ArrayList<>(raw.size());
|
||||
for (String chunk : raw) {
|
||||
try {
|
||||
entries.add(objectMapper.readValue(chunk, ChatbotHistoryEntry.class));
|
||||
} catch (JsonProcessingException ex) {
|
||||
log.debug("Ignoring malformed chatbot history payload", ex);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
Collections.emptyList());
|
||||
}
|
||||
|
||||
public void trimHistory(String sessionId, int retainEntries) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId) || retainEntries <= 0) {
|
||||
return;
|
||||
}
|
||||
execute(
|
||||
() -> {
|
||||
String key = historyKey(sessionId);
|
||||
jedis.ltrim(key, -retainEntries, -1);
|
||||
});
|
||||
}
|
||||
|
||||
public void storeSummary(String sessionId, String summary) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId)) {
|
||||
return;
|
||||
}
|
||||
execute(() -> jedis.setex(summaryKey(sessionId), (int) DEFAULT_TTL.getSeconds(), summary));
|
||||
}
|
||||
|
||||
public String loadSummary(String sessionId) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId)) {
|
||||
return "";
|
||||
}
|
||||
return execute(() -> jedis.get(summaryKey(sessionId)), "");
|
||||
}
|
||||
|
||||
public void clear(String sessionId) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId)) {
|
||||
return;
|
||||
}
|
||||
execute(
|
||||
() -> {
|
||||
jedis.del(historyKey(sessionId));
|
||||
jedis.del(summaryKey(sessionId));
|
||||
});
|
||||
}
|
||||
|
||||
public int defaultWindow() {
|
||||
return DEFAULT_WINDOW;
|
||||
}
|
||||
|
||||
public int retentionWindow() {
|
||||
return RETENTION_WINDOW;
|
||||
}
|
||||
|
||||
public long historyLength(String sessionId) {
|
||||
if (!redisReady() || !StringUtils.hasText(sessionId)) {
|
||||
return 0L;
|
||||
}
|
||||
return execute(() -> jedis.llen(historyKey(sessionId)), 0L);
|
||||
}
|
||||
|
||||
private boolean redisReady() {
|
||||
return jedis != null;
|
||||
}
|
||||
|
||||
private String historyKey(String sessionId) {
|
||||
return HISTORY_KEY.formatted(sessionId);
|
||||
}
|
||||
|
||||
private String summaryKey(String sessionId) {
|
||||
return SUMMARY_KEY.formatted(sessionId);
|
||||
}
|
||||
|
||||
private void execute(Runnable action) {
|
||||
if (!redisReady()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Redis conversation store unavailable: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T execute(Supplier<T> supplier, T fallback) {
|
||||
if (!redisReady()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return supplier.get();
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Redis conversation store unavailable: {}", ex.getMessage());
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory to create entries for manual tests. */
|
||||
public ChatbotHistoryEntry createEntry(
|
||||
String role, String content, String documentId, String documentName) {
|
||||
return new ChatbotHistoryEntry(role, content, documentId, documentName, Instant.now());
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class ChatbotFeatureProperties {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public ChatbotFeatureProperties(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public ChatbotSettings current() {
|
||||
Chatbot chatbot = resolveChatbot();
|
||||
ChatbotSettings.ModelSettings modelSettings =
|
||||
new ChatbotSettings.ModelSettings(
|
||||
resolveProvider(chatbot.getModels().getProvider()),
|
||||
chatbot.getModels().getPrimary(),
|
||||
chatbot.getModels().getFallback(),
|
||||
chatbot.getModels().getEmbedding(),
|
||||
chatbot.getModels().getTopP());
|
||||
return new ChatbotSettings(
|
||||
chatbot.isEnabled(),
|
||||
chatbot.isAlphaWarning(),
|
||||
chatbot.getMaxPromptCharacters(),
|
||||
chatbot.getMinConfidenceNano(),
|
||||
modelSettings,
|
||||
new ChatbotSettings.RagSettings(
|
||||
chatbot.getRag().getChunkSizeTokens(),
|
||||
chatbot.getRag().getChunkOverlapTokens(),
|
||||
chatbot.getRag().getTopK()),
|
||||
new ChatbotSettings.CacheSettings(
|
||||
chatbot.getCache().getTtlMinutes(),
|
||||
chatbot.getCache().getMaxEntries(),
|
||||
chatbot.getCache().getMaxDocumentCharacters()),
|
||||
new ChatbotSettings.OcrSettings(chatbot.getOcr().isEnabledByDefault()),
|
||||
new ChatbotSettings.AuditSettings(chatbot.getAudit().isEnabled()),
|
||||
new ChatbotSettings.UsageSettings(
|
||||
chatbot.getUsage().getPerUserMonthlyTokens(),
|
||||
chatbot.getUsage().getWarnAtRatio()));
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return current().enabled();
|
||||
}
|
||||
|
||||
private Chatbot resolveChatbot() {
|
||||
return Optional.ofNullable(applicationProperties)
|
||||
.map(ApplicationProperties::getPremium)
|
||||
.map(Premium::getProFeatures)
|
||||
.map(ProFeatures::getChatbot)
|
||||
.orElseGet(Chatbot::new);
|
||||
}
|
||||
|
||||
private ChatbotSettings.ModelProvider resolveProvider(String configuredProvider) {
|
||||
if (!StringUtils.hasText(configuredProvider)) {
|
||||
return ChatbotSettings.ModelProvider.OPENAI;
|
||||
}
|
||||
try {
|
||||
return ChatbotSettings.ModelProvider.valueOf(configuredProvider.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return ChatbotSettings.ModelProvider.OPENAI;
|
||||
}
|
||||
}
|
||||
|
||||
public record ChatbotSettings(
|
||||
boolean enabled,
|
||||
boolean alphaWarning,
|
||||
long maxPromptCharacters,
|
||||
double minConfidenceNano,
|
||||
ModelSettings models,
|
||||
RagSettings rag,
|
||||
CacheSettings cache,
|
||||
OcrSettings ocr,
|
||||
AuditSettings audit,
|
||||
UsageSettings usage) {
|
||||
|
||||
public record ModelSettings(
|
||||
ModelProvider provider,
|
||||
String primary,
|
||||
String fallback,
|
||||
String embedding,
|
||||
double topP) {}
|
||||
|
||||
public record RagSettings(int chunkSizeTokens, int chunkOverlapTokens, int topK) {}
|
||||
|
||||
public record CacheSettings(long ttlMinutes, long maxEntries, long maxDocumentCharacters) {}
|
||||
|
||||
public record OcrSettings(boolean enabledByDefault) {}
|
||||
|
||||
public record AuditSettings(boolean enabled) {}
|
||||
|
||||
public record UsageSettings(long perUserMonthlyTokens, double warnAtRatio) {}
|
||||
|
||||
public enum ModelProvider {
|
||||
OPENAI,
|
||||
OLLAMA
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ChatbotIngestionService {
|
||||
|
||||
private final ChatbotCacheService cacheService;
|
||||
private final ChatbotSessionRegistry sessionRegistry;
|
||||
private final ChatbotFeatureProperties featureProperties;
|
||||
private final VectorStore vectorStore;
|
||||
private final ChatbotUsageService usageService;
|
||||
|
||||
public ChatbotSession ingest(ChatbotSessionCreateRequest request) {
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
if (!settings.enabled()) {
|
||||
throw new ChatbotException("Chatbot feature is disabled");
|
||||
}
|
||||
if (!request.isWarningsAccepted() && settings.alphaWarning()) {
|
||||
throw new ChatbotException("Alpha warning must be accepted before use");
|
||||
}
|
||||
if (!StringUtils.hasText(request.getText())) {
|
||||
throw new NoTextDetectedException(
|
||||
"No text detected in document payload. Images are currently unsupported – enable OCR to continue.");
|
||||
}
|
||||
|
||||
long characterLimit = cacheService.getMaxDocumentCharacters();
|
||||
long textCharacters = request.getText().length();
|
||||
if (textCharacters > characterLimit) {
|
||||
throw new ChatbotException(
|
||||
"Document text exceeds maximum allowed characters: " + characterLimit);
|
||||
}
|
||||
|
||||
String sessionId =
|
||||
StringUtils.hasText(request.getSessionId())
|
||||
? request.getSessionId()
|
||||
: ChatbotSession.randomSessionId();
|
||||
boolean imagesDetected = request.isImagesDetected();
|
||||
boolean ocrApplied = request.isOcrRequested();
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
if (request.getMetadata() != null) {
|
||||
metadata.putAll(request.getMetadata());
|
||||
}
|
||||
metadata.put("content.imagesDetected", Boolean.toString(imagesDetected));
|
||||
metadata.put("content.characterCount", String.valueOf(textCharacters));
|
||||
metadata.put(
|
||||
"content.extractionSource", ocrApplied ? "ocr-text-layer" : "embedded-text-layer");
|
||||
Map<String, String> immutableMetadata = Map.copyOf(metadata);
|
||||
|
||||
List<Document> documents =
|
||||
buildDocuments(
|
||||
sessionId, request.getDocumentId(), request.getText(), metadata, settings);
|
||||
try {
|
||||
vectorStore.add(documents);
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ChatbotException(
|
||||
"Failed to index document content in vector store: "
|
||||
+ sanitizeRemoteMessage(ex.getMessage()),
|
||||
ex);
|
||||
}
|
||||
|
||||
String cacheKey =
|
||||
cacheService.register(
|
||||
sessionId,
|
||||
request.getDocumentId(),
|
||||
immutableMetadata,
|
||||
ocrApplied,
|
||||
imagesDetected,
|
||||
textCharacters);
|
||||
|
||||
long estimatedTokens = Math.max(1L, Math.round(textCharacters / 4.0));
|
||||
|
||||
ChatbotSession session =
|
||||
ChatbotSession.builder()
|
||||
.sessionId(sessionId)
|
||||
.documentId(request.getDocumentId())
|
||||
.userId(request.getUserId())
|
||||
.metadata(immutableMetadata)
|
||||
.ocrRequested(ocrApplied)
|
||||
.imageContentDetected(imagesDetected)
|
||||
.textCharacters(textCharacters)
|
||||
.estimatedTokens(estimatedTokens)
|
||||
.warningsAccepted(request.isWarningsAccepted())
|
||||
.alphaWarningRequired(settings.alphaWarning())
|
||||
.cacheKey(cacheKey)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
session.setUsageSummary(
|
||||
usageService.registerIngestion(session.getUserId(), estimatedTokens));
|
||||
sessionRegistry.register(session);
|
||||
log.info(
|
||||
"Registered chatbot session {} for document {} with {} RAG chunks",
|
||||
sessionId,
|
||||
request.getDocumentId(),
|
||||
documents.size());
|
||||
return session;
|
||||
}
|
||||
|
||||
private List<Document> buildDocuments(
|
||||
String sessionId,
|
||||
String documentId,
|
||||
String text,
|
||||
Map<String, String> metadata,
|
||||
ChatbotSettings settings) {
|
||||
List<Document> documents = new ArrayList<>();
|
||||
if (!StringUtils.hasText(text)) {
|
||||
return documents;
|
||||
}
|
||||
|
||||
int chunkChars = Math.max(512, settings.rag().chunkSizeTokens() * 4);
|
||||
int overlapChars = Math.max(64, settings.rag().chunkOverlapTokens() * 4);
|
||||
|
||||
int index = 0;
|
||||
int order = 0;
|
||||
while (index < text.length()) {
|
||||
int end = Math.min(text.length(), index + chunkChars);
|
||||
String chunk = text.substring(index, end).trim();
|
||||
if (!chunk.isEmpty()) {
|
||||
Document document = new Document(chunk);
|
||||
document.getMetadata().putAll(metadata);
|
||||
document.getMetadata().put("sessionId", sessionId);
|
||||
document.getMetadata().put("documentId", documentId);
|
||||
document.getMetadata().put("chunkOrder", Integer.toString(order));
|
||||
documents.add(document);
|
||||
order++;
|
||||
}
|
||||
if (end == text.length()) {
|
||||
break;
|
||||
}
|
||||
int nextIndex = end - overlapChars;
|
||||
if (nextIndex <= index) {
|
||||
nextIndex = end;
|
||||
}
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
if (documents.isEmpty()) {
|
||||
throw new ChatbotException("Unable to split document text into searchable chunks");
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
private String sanitizeRemoteMessage(String message) {
|
||||
if (!StringUtils.hasText(message)) {
|
||||
return "unexpected provider error";
|
||||
}
|
||||
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChatbotMemoryService {
|
||||
|
||||
private final VectorStore vectorStore;
|
||||
|
||||
public void recordTurn(ChatbotSession session, String prompt, String answer) {
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
if (!StringUtils.hasText(prompt) && !StringUtils.hasText(answer)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("sessionId", session.getSessionId());
|
||||
metadata.put("documentId", session.getDocumentId());
|
||||
metadata.put("turnType", "conversation");
|
||||
metadata.put("turnTimestamp", Instant.now().toString());
|
||||
metadata.put("userId", session.getUserId());
|
||||
|
||||
StringBuilder contentBuilder = new StringBuilder();
|
||||
if (StringUtils.hasText(prompt)) {
|
||||
contentBuilder.append("User: ").append(prompt.trim()).append("\n");
|
||||
}
|
||||
if (StringUtils.hasText(answer)) {
|
||||
contentBuilder.append("Assistant: ").append(answer.trim());
|
||||
}
|
||||
try {
|
||||
vectorStore.add(List.of(new Document(contentBuilder.toString(), metadata)));
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Failed to persist chatbot conversation turn: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ChatbotRetrievalService {
|
||||
|
||||
private final ChatbotCacheService cacheService;
|
||||
private final VectorStore vectorStore;
|
||||
private final Cache<String, List<Document>> retrievalCache =
|
||||
Caffeine.newBuilder().maximumSize(200).expireAfterWrite(30, TimeUnit.SECONDS).build();
|
||||
|
||||
public List<Document> retrieveTopK(String sessionId, String query, ChatbotSettings settings) {
|
||||
cacheService
|
||||
.resolveBySessionId(sessionId)
|
||||
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
|
||||
|
||||
int topK = Math.max(settings.rag().topK(), 1);
|
||||
String sanitizedQuery = StringUtils.hasText(query) ? query : "";
|
||||
String filterExpression = "metadata.sessionId == '" + escape(sessionId) + "'";
|
||||
String cacheKey = cacheKey(sessionId, sanitizedQuery, topK);
|
||||
List<Document> cached = retrievalCache.getIfPresent(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
SearchRequest searchRequest =
|
||||
SearchRequest.builder()
|
||||
.query(sanitizedQuery)
|
||||
.topK(topK)
|
||||
.filterExpression(filterExpression)
|
||||
.similarityThreshold(0.7f)
|
||||
.build();
|
||||
List<Document> results;
|
||||
|
||||
try {
|
||||
results = vectorStore.similaritySearch(searchRequest);
|
||||
} catch (RuntimeException ex) {
|
||||
throw new ChatbotException(
|
||||
"Failed to perform vector similarity search: "
|
||||
+ sanitizeRemoteMessage(ex.getMessage()),
|
||||
ex);
|
||||
}
|
||||
results =
|
||||
results.stream()
|
||||
.filter(
|
||||
doc ->
|
||||
sessionId.equals(
|
||||
doc.getMetadata().getOrDefault("sessionId", "")))
|
||||
.limit(topK)
|
||||
.toList();
|
||||
if (results.isEmpty()) {
|
||||
log.warn("No context available for chatbot session {}", sessionId);
|
||||
}
|
||||
|
||||
List<Document> immutableResults = List.copyOf(results);
|
||||
retrievalCache.put(cacheKey, immutableResults);
|
||||
return immutableResults;
|
||||
}
|
||||
|
||||
private String sanitizeRemoteMessage(String message) {
|
||||
if (!StringUtils.hasText(message)) {
|
||||
return "unexpected provider error";
|
||||
}
|
||||
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
return value.replace("'", "\\'");
|
||||
}
|
||||
|
||||
private String cacheKey(String sessionId, String query, int topK) {
|
||||
return sessionId + "::" + Objects.hash(query, topK);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.AuditService;
|
||||
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ChatbotService {
|
||||
|
||||
private final ChatbotIngestionService ingestionService;
|
||||
private final ChatbotConversationService conversationService;
|
||||
private final ChatbotSessionRegistry sessionRegistry;
|
||||
private final ChatbotCacheService cacheService;
|
||||
private final ChatbotFeatureProperties featureProperties;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
public ChatbotSession createSession(ChatbotSessionCreateRequest request) {
|
||||
if (!StringUtils.hasText(request.getUserId())) {
|
||||
request.setUserId(userService.getCurrentUsername());
|
||||
}
|
||||
ChatbotSession session = ingestionService.ingest(request);
|
||||
log.debug("Chatbot session {} initialised", session.getSessionId());
|
||||
audit(
|
||||
"CHATBOT_SESSION_CREATED",
|
||||
session.getSessionId(),
|
||||
Map.of(
|
||||
"documentId", session.getDocumentId(),
|
||||
"ocrRequested", session.isOcrRequested(),
|
||||
"imagesDetected", session.isImageContentDetected(),
|
||||
"textCharacters", session.getTextCharacters()));
|
||||
return session;
|
||||
}
|
||||
|
||||
public ChatbotResponse ask(ChatbotQueryRequest request) {
|
||||
ChatbotResponse response = conversationService.handleQuery(request);
|
||||
audit(
|
||||
"CHATBOT_QUERY",
|
||||
request.getSessionId(),
|
||||
Map.of(
|
||||
"modelUsed", response.getModelUsed(),
|
||||
"escalated", response.isEscalated(),
|
||||
"confidence", response.getConfidence()));
|
||||
return response;
|
||||
}
|
||||
|
||||
public void close(String sessionId) {
|
||||
sessionRegistry
|
||||
.findById(sessionId)
|
||||
.orElseThrow(() -> new ChatbotException("Session not found for closure"));
|
||||
sessionRegistry.remove(sessionId);
|
||||
cacheService.invalidateSession(sessionId);
|
||||
audit("CHATBOT_SESSION_CLOSED", sessionId, Map.of());
|
||||
log.debug("Chatbot session {} closed", sessionId);
|
||||
}
|
||||
|
||||
private void audit(String action, String sessionId, Map<String, Object> data) {
|
||||
if (!featureProperties.current().audit().enabled()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>(data == null ? Map.of() : data);
|
||||
payload.put("sessionId", sessionId);
|
||||
auditService.audit(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS, payload);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
|
||||
@Component
|
||||
public class ChatbotSessionRegistry {
|
||||
|
||||
private final Map<String, ChatbotSession> sessionStore = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> documentToSession = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(ChatbotSession session) {
|
||||
sessionStore.put(session.getSessionId(), session);
|
||||
if (session.getDocumentId() != null) {
|
||||
documentToSession.put(session.getDocumentId(), session.getSessionId());
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<ChatbotSession> findById(String sessionId) {
|
||||
return Optional.ofNullable(sessionStore.get(sessionId));
|
||||
}
|
||||
|
||||
public void remove(String sessionId) {
|
||||
Optional.ofNullable(sessionStore.remove(sessionId))
|
||||
.map(ChatbotSession::getDocumentId)
|
||||
.ifPresent(documentToSession::remove);
|
||||
}
|
||||
|
||||
public Optional<ChatbotSession> findByDocumentId(String documentId) {
|
||||
return Optional.ofNullable(documentToSession.get(documentId)).flatMap(this::findById);
|
||||
}
|
||||
|
||||
public void removeByDocumentId(String documentId) {
|
||||
Optional.ofNullable(documentToSession.remove(documentId)).ifPresent(sessionStore::remove);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ChatbotUsageService {
|
||||
|
||||
private final ChatbotFeatureProperties featureProperties;
|
||||
|
||||
private final Map<String, UsageWindow> usageByUser = new ConcurrentHashMap<>();
|
||||
|
||||
public ChatbotUsageSummary registerIngestion(String userId, long estimatedTokens) {
|
||||
return incrementUsage(userId, Math.max(estimatedTokens, 0L));
|
||||
}
|
||||
|
||||
public ChatbotUsageSummary registerGeneration(
|
||||
String userId, long promptTokens, long completionTokens) {
|
||||
long total = Math.max(promptTokens + completionTokens, 0L);
|
||||
return incrementUsage(userId, total);
|
||||
}
|
||||
|
||||
public ChatbotUsageSummary currentUsage(String userId) {
|
||||
String key = normalizeUserId(userId);
|
||||
UsageWindow window = usageByUser.get(key);
|
||||
if (window == null) {
|
||||
return buildSummary(key, 0L, 0L);
|
||||
}
|
||||
return buildSummary(key, window.tokens.get(), 0L);
|
||||
}
|
||||
|
||||
private ChatbotUsageSummary incrementUsage(String userId, long deltaTokens) {
|
||||
String key = normalizeUserId(userId);
|
||||
YearMonth now = YearMonth.now(ZoneOffset.UTC);
|
||||
UsageWindow window =
|
||||
usageByUser.compute(
|
||||
key,
|
||||
(ignored, existing) -> {
|
||||
if (existing == null || !existing.window.equals(now)) {
|
||||
existing = new UsageWindow(now);
|
||||
}
|
||||
if (deltaTokens > 0) {
|
||||
existing.tokens.addAndGet(deltaTokens);
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
return buildSummary(key, window.tokens.get(), deltaTokens);
|
||||
}
|
||||
|
||||
private ChatbotUsageSummary buildSummary(String userKey, long consumed, long deltaTokens) {
|
||||
ChatbotSettings settings = featureProperties.current();
|
||||
long allocation = Math.max(settings.usage().perUserMonthlyTokens(), 1L);
|
||||
double ratio = allocation == 0 ? 1.0 : (double) consumed / allocation;
|
||||
long remaining = Math.max(allocation - consumed, 0L);
|
||||
boolean limitExceeded = consumed > allocation;
|
||||
boolean nearingLimit = ratio >= settings.usage().warnAtRatio();
|
||||
return ChatbotUsageSummary.builder()
|
||||
.allocatedTokens(allocation)
|
||||
.consumedTokens(consumed)
|
||||
.remainingTokens(remaining)
|
||||
.usageRatio(Math.min(ratio, 1.0))
|
||||
.nearingLimit(nearingLimit)
|
||||
.limitExceeded(limitExceeded)
|
||||
.lastIncrementTokens(deltaTokens)
|
||||
.window(currentWindowDescription(userKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String currentWindowDescription(String userKey) {
|
||||
UsageWindow window = usageByUser.get(userKey);
|
||||
if (window == null) {
|
||||
return YearMonth.now(ZoneOffset.UTC).toString();
|
||||
}
|
||||
return window.window.toString();
|
||||
}
|
||||
|
||||
private String normalizeUserId(String userId) {
|
||||
if (!StringUtils.hasText(userId)) {
|
||||
return "anonymous";
|
||||
}
|
||||
return userId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private static final class UsageWindow {
|
||||
private final YearMonth window;
|
||||
private final AtomicLong tokens = new AtomicLong();
|
||||
|
||||
private UsageWindow(YearMonth window) {
|
||||
this.window = window;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.service.chatbot.exception;
|
||||
|
||||
public class ChatbotException extends RuntimeException {
|
||||
|
||||
public ChatbotException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ChatbotException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.service.chatbot.exception;
|
||||
|
||||
public class NoTextDetectedException extends ChatbotException {
|
||||
|
||||
public NoTextDetectedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Spring AI OpenAI Configuration
|
||||
# Uses GPT-5-nano as primary model and GPT-5-mini as fallback (configured in settings.yml)
|
||||
spring.ai.openai.enabled=true
|
||||
#spring.ai.openai.api-key=# todo <API-KEY-HERE>
|
||||
spring.ai.openai.base-url=https://api.openai.com
|
||||
spring.ai.openai.chat.enabled=true
|
||||
spring.ai.openai.chat.options.model=gpt-5-nano
|
||||
# Note: Some models only support default temperature value of 1.0
|
||||
spring.ai.openai.chat.options.temperature=1.0
|
||||
# For newer models, use max-completion-tokens instead of max-tokens
|
||||
spring.ai.openai.chat.options.max-completion-tokens=4000
|
||||
spring.ai.openai.embedding.enabled=true
|
||||
spring.ai.openai.embedding.options.model=text-embedding-ada-002
|
||||
# Increase timeout for OpenAI API calls (default is 10 seconds)
|
||||
spring.ai.openai.chat.options.connection-timeout=60s
|
||||
spring.ai.openai.chat.options.read-timeout=60s
|
||||
spring.ai.openai.embedding.options.connection-timeout=60s
|
||||
spring.ai.openai.embedding.options.read-timeout=60s
|
||||
|
||||
# Spring AI Ollama Configuration (disabled to avoid bean conflicts)
|
||||
spring.ai.ollama.enabled=false
|
||||
spring.ai.ollama.base-url=http://localhost:11434
|
||||
spring.ai.ollama.chat.enabled=false
|
||||
spring.ai.ollama.chat.options.model=llama3
|
||||
spring.ai.ollama.chat.options.temperature=1.0
|
||||
spring.ai.ollama.embedding.enabled=false
|
||||
spring.ai.ollama.embedding.options.model=nomic-embed-text
|
||||
|
||||
spring.data.redis.host=localhost
|
||||
spring.data.redis.port=6379
|
||||
spring.data.redis.password=
|
||||
spring.data.redis.timeout=60000
|
||||
spring.data.redis.ssl.enabled=false
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
|
||||
|
||||
class ChatbotCacheServiceTest {
|
||||
|
||||
private ApplicationProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
properties = new ApplicationProperties();
|
||||
ApplicationProperties.Premium premium = new ApplicationProperties.Premium();
|
||||
ApplicationProperties.Premium.ProFeatures pro =
|
||||
new ApplicationProperties.Premium.ProFeatures();
|
||||
ApplicationProperties.Premium.ProFeatures.Chatbot chatbot =
|
||||
new ApplicationProperties.Premium.ProFeatures.Chatbot();
|
||||
chatbot.setEnabled(true);
|
||||
chatbot.getCache().setMaxDocumentCharacters(50);
|
||||
chatbot.getCache().setMaxEntries(10);
|
||||
chatbot.getCache().setTtlMinutes(60);
|
||||
pro.setChatbot(chatbot);
|
||||
premium.setProFeatures(pro);
|
||||
properties.setPremium(premium);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAndResolveSession() {
|
||||
ChatbotCacheService cacheService = new ChatbotCacheService(properties);
|
||||
String cacheKey =
|
||||
cacheService.register(
|
||||
"session1",
|
||||
"doc1",
|
||||
Map.of("title", "Sample"),
|
||||
false,
|
||||
false,
|
||||
"hello world".length());
|
||||
assertTrue(cacheService.resolveBySessionId("session1").isPresent());
|
||||
ChatbotDocumentCacheEntry entry = cacheService.resolveByCacheKey(cacheKey).orElseThrow();
|
||||
assertEquals("doc1", entry.getDocumentId());
|
||||
assertEquals("Sample", entry.getMetadata().get("title"));
|
||||
assertEquals("hello world".length(), entry.getTextCharacters());
|
||||
assertTrue(!entry.isImageContentDetected());
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChatbotConversationServiceTest {
|
||||
|
||||
@Mock private ChatModel chatModel;
|
||||
@Mock private ChatbotSessionRegistry sessionRegistry;
|
||||
@Mock private ChatbotCacheService cacheService;
|
||||
@Mock private ChatbotFeatureProperties featureProperties;
|
||||
@Mock private ChatbotRetrievalService retrievalService;
|
||||
@Mock private ChatbotContextCompressor contextCompressor;
|
||||
@Mock private ChatbotMemoryService memoryService;
|
||||
@Mock private ChatbotUsageService usageService;
|
||||
@Mock private ChatbotConversationStore conversationStore;
|
||||
|
||||
private ChatbotConversationService conversationService;
|
||||
private ChatbotSettings defaultSettings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
conversationService =
|
||||
new ChatbotConversationService(
|
||||
chatModel,
|
||||
sessionRegistry,
|
||||
cacheService,
|
||||
featureProperties,
|
||||
retrievalService,
|
||||
contextCompressor,
|
||||
memoryService,
|
||||
usageService,
|
||||
conversationStore,
|
||||
new ObjectMapper());
|
||||
|
||||
defaultSettings =
|
||||
new ChatbotSettings(
|
||||
true,
|
||||
true,
|
||||
4000,
|
||||
0.65D,
|
||||
new ChatbotSettings.ModelSettings(
|
||||
ChatbotSettings.ModelProvider.OPENAI,
|
||||
"gpt-5-nano",
|
||||
"gpt-5-mini",
|
||||
"embed",
|
||||
0.95D),
|
||||
new ChatbotSettings.RagSettings(512, 128, 4),
|
||||
new ChatbotSettings.CacheSettings(60, 10, 1000),
|
||||
new ChatbotSettings.OcrSettings(false),
|
||||
new ChatbotSettings.AuditSettings(false),
|
||||
new ChatbotSettings.UsageSettings(100_000L, 0.7D));
|
||||
}
|
||||
|
||||
@Test
|
||||
void summarizesAndTrimsHistoryWhenThresholdReached() {
|
||||
ChatbotSession session =
|
||||
ChatbotSession.builder()
|
||||
.sessionId("session-1")
|
||||
.documentId("doc-123")
|
||||
.metadata(Map.of("documentName", "Quarterly Report"))
|
||||
.build();
|
||||
|
||||
when(conversationStore.defaultWindow()).thenReturn(2);
|
||||
when(conversationStore.retentionWindow()).thenReturn(10);
|
||||
when(conversationStore.historyLength("session-1")).thenReturn(6L);
|
||||
when(conversationStore.getRecentTurns("session-1", 10))
|
||||
.thenReturn(historyEntries(6, "doc-123", "Quarterly Report"));
|
||||
when(conversationStore.loadSummary("session-1")).thenReturn("previous summary");
|
||||
when(chatModel.call(any(Prompt.class)))
|
||||
.thenReturn(
|
||||
new ChatResponse(
|
||||
List.of(new Generation(new AssistantMessage("updated summary")))));
|
||||
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
conversationService, "summarizeConversation", defaultSettings, session);
|
||||
|
||||
verify(chatModel, times(1)).call(any(Prompt.class));
|
||||
verify(conversationStore).storeSummary("session-1", "updated summary");
|
||||
verify(conversationStore).trimHistory("session-1", 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSummarizationWhenHistoryBelowThreshold() {
|
||||
ChatbotSession session =
|
||||
ChatbotSession.builder().sessionId("session-2").documentId("doc").build();
|
||||
|
||||
when(conversationStore.defaultWindow()).thenReturn(4);
|
||||
when(conversationStore.historyLength("session-2")).thenReturn(5L);
|
||||
|
||||
ReflectionTestUtils.invokeMethod(
|
||||
conversationService, "summarizeConversation", defaultSettings, session);
|
||||
|
||||
verify(chatModel, never()).call(any(org.springframework.ai.chat.prompt.Prompt.class));
|
||||
verify(conversationStore, never()).storeSummary(anyString(), anyString());
|
||||
verify(conversationStore, never()).trimHistory(anyString(), anyInt());
|
||||
}
|
||||
|
||||
private List<ChatbotHistoryEntry> historyEntries(
|
||||
int count, String documentId, String documentName) {
|
||||
List<ChatbotHistoryEntry> entries = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
entries.add(
|
||||
new ChatbotHistoryEntry(
|
||||
i % 2 == 0 ? "user" : "assistant",
|
||||
"message-" + i,
|
||||
documentId,
|
||||
documentName,
|
||||
Instant.now().minusSeconds(60L - i)));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.service.chatbot;
|
||||
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSession;
|
||||
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.AuditService;
|
||||
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChatbotServiceTest {
|
||||
|
||||
@Mock private ChatbotIngestionService ingestionService;
|
||||
@Mock private ChatbotConversationService conversationService;
|
||||
@Mock private ChatbotSessionRegistry sessionRegistry;
|
||||
@Mock private ChatbotCacheService cacheService;
|
||||
@Mock private ChatbotFeatureProperties featureProperties;
|
||||
@Mock private AuditService auditService;
|
||||
@Mock private UserService userService;
|
||||
|
||||
@InjectMocks private ChatbotService chatbotService;
|
||||
|
||||
private ChatbotSettings auditEnabledSettings;
|
||||
private ChatbotSettings auditDisabledSettings;
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
auditEnabledSettings =
|
||||
new ChatbotSettings(
|
||||
true,
|
||||
true,
|
||||
4000,
|
||||
0.5D,
|
||||
new ChatbotSettings.ModelSettings(
|
||||
ChatbotSettings.ModelProvider.OPENAI,
|
||||
"gpt-5-nano",
|
||||
"gpt-5-mini",
|
||||
"embed",
|
||||
0.95D),
|
||||
new ChatbotSettings.RagSettings(512, 128, 4),
|
||||
new ChatbotSettings.CacheSettings(60, 10, 1000),
|
||||
new ChatbotSettings.OcrSettings(false),
|
||||
new ChatbotSettings.AuditSettings(true),
|
||||
new ChatbotSettings.UsageSettings(100000L, 0.7D));
|
||||
|
||||
auditDisabledSettings =
|
||||
new ChatbotSettings(
|
||||
true,
|
||||
true,
|
||||
4000,
|
||||
0.5D,
|
||||
new ChatbotSettings.ModelSettings(
|
||||
ChatbotSettings.ModelProvider.OPENAI,
|
||||
"gpt-5-nano",
|
||||
"gpt-5-mini",
|
||||
"embed",
|
||||
0.95D),
|
||||
new ChatbotSettings.RagSettings(512, 128, 4),
|
||||
new ChatbotSettings.CacheSettings(60, 10, 1000),
|
||||
new ChatbotSettings.OcrSettings(false),
|
||||
new ChatbotSettings.AuditSettings(false),
|
||||
new ChatbotSettings.UsageSettings(100000L, 0.7D));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSessionEmitsAuditWhenEnabled() {
|
||||
ChatbotSession session =
|
||||
ChatbotSession.builder()
|
||||
.sessionId("session-1")
|
||||
.documentId("doc-1")
|
||||
.ocrRequested(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
when(ingestionService.ingest(any())).thenReturn(session);
|
||||
when(featureProperties.current()).thenReturn(auditEnabledSettings);
|
||||
when(userService.getCurrentUsername()).thenReturn("tester");
|
||||
|
||||
chatbotService.createSession(
|
||||
ChatbotSessionCreateRequest.builder().text("abc").warningsAccepted(true).build());
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
verify(auditService)
|
||||
.audit(
|
||||
eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS),
|
||||
payloadCaptor.capture());
|
||||
Map<String, Object> payload = payloadCaptor.getValue();
|
||||
verify(cacheService, times(0)).invalidateSession(any());
|
||||
verify(userService).getCurrentUsername();
|
||||
org.junit.jupiter.api.Assertions.assertEquals("session-1", payload.get("sessionId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void querySkipsAuditWhenDisabled() {
|
||||
ChatbotQueryRequest request =
|
||||
ChatbotQueryRequest.builder()
|
||||
.sessionId("session-2")
|
||||
.prompt("Hello?")
|
||||
.allowEscalation(true)
|
||||
.build();
|
||||
ChatbotResponse response =
|
||||
ChatbotResponse.builder()
|
||||
.sessionId("session-2")
|
||||
.modelUsed("gpt-5-nano")
|
||||
.confidence(0.8D)
|
||||
.build();
|
||||
when(conversationService.handleQuery(request)).thenReturn(response);
|
||||
when(featureProperties.current()).thenReturn(auditDisabledSettings);
|
||||
|
||||
chatbotService.ask(request);
|
||||
|
||||
verify(auditService, times(0))
|
||||
.audit(eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeSessionInvalidatesCache() {
|
||||
ChatbotSession session =
|
||||
ChatbotSession.builder().sessionId("session-3").documentId("doc").build();
|
||||
when(sessionRegistry.findById("session-3")).thenReturn(Optional.of(session));
|
||||
when(featureProperties.current()).thenReturn(auditEnabledSettings);
|
||||
|
||||
chatbotService.close("session-3");
|
||||
|
||||
verify(sessionRegistry).remove("session-3");
|
||||
verify(cacheService).invalidateSession("session-3");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import com.github.jk1.license.render.*
|
||||
|
||||
ext {
|
||||
springBootVersion = "3.5.6"
|
||||
springAiVersion = "1.0.3"
|
||||
pdfboxVersion = "3.0.5"
|
||||
imageioVersion = "3.12.0"
|
||||
lombokVersion = "1.18.42"
|
||||
@@ -93,6 +94,8 @@ subprojects {
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = 'https://repo.spring.io/release' }
|
||||
maven { url 'https://repo.spring.io/milestone' }
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
@@ -107,6 +110,7 @@ subprojects {
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
|
||||
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -587,3 +587,8 @@ In your Thymeleaf templates, use the `#{key}` syntax to reference the new transl
|
||||
```
|
||||
|
||||
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
|
||||
|
||||
### Chatbot Feature Configuration
|
||||
|
||||
- The chatbot backend is disabled unless `premium.proFeatures.chatbot.enabled` is true in `configs/settings.yml`.
|
||||
- Provide an OpenAI-compatible key via `SPRING_AI_OPENAI_API_KEY` (or `spring.ai.openai.api-key`) and set `spring.ai.openai.enabled=true` when you want chatbot beans to load. Leaving this property disabled allows the rest of Stirling-PDF to run without AI credentials.
|
||||
|
||||
@@ -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
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
<ChatbotProvider>
|
||||
{children}
|
||||
</ChatbotProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useMediaQuery, useViewportSize } 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 { useChatbot } from '@app/contexts/ChatbotContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import {
|
||||
ChatbotMessageResponse,
|
||||
ChatbotSessionInfo,
|
||||
ChatbotUsageSummary,
|
||||
sendChatbotPrompt,
|
||||
} from '@app/services/chatbotService';
|
||||
import { useToast } from '@app/components/toast';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
confidence?: number;
|
||||
modelUsed?: string;
|
||||
createdAt: Date;
|
||||
documentId?: string;
|
||||
documentName?: string;
|
||||
}
|
||||
|
||||
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 ALPHA_ACK_KEY = 'stirling.chatbot.alphaAck';
|
||||
|
||||
const ChatbotDrawer = () => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const {
|
||||
isOpen,
|
||||
closeChat,
|
||||
preferredFileId,
|
||||
setPreferredFileId,
|
||||
sessions: preparedSessions,
|
||||
requestPreprocessing,
|
||||
} = useChatbot();
|
||||
const { selectors } = useFileState();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { show } = useToast();
|
||||
const files = selectors.getFiles();
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
|
||||
const [alphaAccepted, setAlphaAccepted] = useState(false);
|
||||
const [runOcr, setRunOcr] = useState(false);
|
||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||
const [isSendingMessage, setIsSendingMessage] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
const [panelAnchor, setPanelAnchor] = useState<{ right: number; top: number } | null>(null);
|
||||
const usageAlertState = useRef<'none' | 'warned' | 'limit'>('none');
|
||||
|
||||
const selectedFile = useMemo<StirlingFile | undefined>(
|
||||
() => files.find((file) => file.fileId === selectedFileId),
|
||||
[files, selectedFileId]
|
||||
);
|
||||
const selectedSessionEntry = selectedFileId
|
||||
? preparedSessions[selectedFileId]
|
||||
: undefined;
|
||||
const sessionStatus = selectedSessionEntry?.status ?? 'idle';
|
||||
const sessionError = selectedSessionEntry?.error;
|
||||
const sessionInfo: ChatbotSessionInfo | null = selectedSessionEntry?.session ?? null;
|
||||
const selectedDocumentName = selectedFile?.name ?? selectedSessionEntry?.fileName;
|
||||
const contextStats =
|
||||
selectedSessionEntry?.status === 'ready' && selectedSessionEntry?.characterCount !== undefined
|
||||
? {
|
||||
pageCount: selectedSessionEntry.pageCount ?? 0,
|
||||
characterCount: selectedSessionEntry.characterCount ?? 0,
|
||||
}
|
||||
: null;
|
||||
const preparationWarnings = selectedSessionEntry?.warnings ?? [];
|
||||
const derivedStatusMessage = useMemo(() => {
|
||||
if (!alphaAccepted) {
|
||||
return t('chatbot.autoSyncPrompt', 'Acknowledge the alpha notice to start syncing automatically.');
|
||||
}
|
||||
if (sessionStatus === 'processing' || isStartingSession) {
|
||||
return t('chatbot.status.syncing', 'Preparing document for chat…');
|
||||
}
|
||||
if (sessionStatus === 'error') {
|
||||
return sessionError || t('chatbot.errors.preprocessing', 'Unable to prepare this document.');
|
||||
}
|
||||
if (sessionStatus === 'unsupported') {
|
||||
return sessionError || t('chatbot.errors.unsupported', 'Unsupported document type.');
|
||||
}
|
||||
return null;
|
||||
}, [alphaAccepted, sessionStatus, sessionError, isStartingSession, t]);
|
||||
const assistantWarnings = useMemo(
|
||||
() => [...preparationWarnings, ...warnings.filter(Boolean)],
|
||||
[preparationWarnings, warnings]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const storedAck =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(ALPHA_ACK_KEY) === 'true'
|
||||
: false;
|
||||
setAlphaAccepted(storedAck);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
usageAlertState.current = 'none';
|
||||
if (sessionInfo) {
|
||||
maybeShowUsageWarning(sessionInfo.usageSummary);
|
||||
}
|
||||
}, [sessionInfo?.sessionId]);
|
||||
|
||||
const maybeShowUsageWarning = (usage?: ChatbotUsageSummary | null) => {
|
||||
if (!usage) {
|
||||
return;
|
||||
}
|
||||
if (usage.limitExceeded && usageAlertState.current !== 'limit') {
|
||||
usageAlertState.current = 'limit';
|
||||
show({
|
||||
alertType: 'warning',
|
||||
title: t('chatbot.usage.limitReachedTitle', 'Chatbot limit reached'),
|
||||
body: t(
|
||||
'chatbot.usage.limitReachedBody',
|
||||
'You have exceeded the current monthly allocation for the chatbot. Further responses may be throttled.'
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (usage.nearingLimit && usageAlertState.current === 'none') {
|
||||
usageAlertState.current = 'warned';
|
||||
show({
|
||||
alertType: 'warning',
|
||||
title: t('chatbot.usage.nearingLimitTitle', 'Approaching usage limit'),
|
||||
body: t(
|
||||
'chatbot.usage.nearingLimitBody',
|
||||
'You are nearing your monthly chatbot allocation. Consider limiting very large requests.'
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 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 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 handleManualPrepare = async (forceOcr?: boolean) => {
|
||||
if (!ensureFileSelected() || !selectedFileId) {
|
||||
return;
|
||||
}
|
||||
setIsStartingSession(true);
|
||||
try {
|
||||
await requestPreprocessing(selectedFileId, { force: true, forceOcr: forceOcr ?? runOcr });
|
||||
usageAlertState.current = 'none';
|
||||
} catch (error) {
|
||||
console.error('[Chatbot] Failed to prepare document', error);
|
||||
show({
|
||||
alertType: 'error',
|
||||
title: t('chatbot.toasts.failedSessionTitle', 'Could not prepare document'),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setIsStartingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!sessionInfo || sessionStatus !== 'ready') {
|
||||
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(),
|
||||
documentId: selectedFileId,
|
||||
documentName: selectedDocumentName,
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setPrompt('');
|
||||
setIsSendingMessage(true);
|
||||
|
||||
try {
|
||||
const reply = await sendChatbotPrompt({
|
||||
sessionId: sessionInfo.sessionId,
|
||||
prompt: trimmedPrompt,
|
||||
allowEscalation: true,
|
||||
});
|
||||
maybeShowUsageWarning(reply.usageSummary);
|
||||
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(),
|
||||
documentId: selectedFileId,
|
||||
documentName: selectedDocumentName,
|
||||
});
|
||||
|
||||
const fileOptions = useMemo(
|
||||
() =>
|
||||
files.map((file) => ({
|
||||
value: file.fileId,
|
||||
label: `${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`,
|
||||
})),
|
||||
[files]
|
||||
);
|
||||
|
||||
const disablePromptInput =
|
||||
!sessionInfo || sessionStatus !== 'ready' || 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 = (
|
||||
<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 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 (
|
||||
<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" align="flex-start">
|
||||
<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>
|
||||
)}
|
||||
{message.documentName && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={isUser ? 'blue' : 'gray'}
|
||||
mt={6}
|
||||
>
|
||||
{message.documentName}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={closeChat}
|
||||
withCloseButton
|
||||
radius="lg"
|
||||
overlayProps={{ opacity: 0.5, blur: 2 }}
|
||||
fullScreen={isMobile}
|
||||
centered={isMobile}
|
||||
title={drawerTitle}
|
||||
styles={{
|
||||
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="sm" h="100%" style={{ minHeight: 0 }}>
|
||||
<Box
|
||||
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.'
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Group align="flex-end" justify="space-between" gap="md" wrap="wrap">
|
||||
<Select
|
||||
label={t('chatbot.fileLabel', 'Document')}
|
||||
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.')}
|
||||
style={{ flex: '1 1 200px' }}
|
||||
/>
|
||||
<Stack gap={4} style={{ minWidth: 180 }}>
|
||||
<Switch
|
||||
checked={alphaAccepted}
|
||||
onChange={(event) => handleAlphaAccept(event.currentTarget.checked)}
|
||||
label={t('chatbot.acceptAlphaLabel', 'I acknowledge this experimental feature')}
|
||||
/>
|
||||
<Switch
|
||||
checked={runOcr}
|
||||
onChange={(event) => setRunOcr(event.currentTarget.checked)}
|
||||
label={t('chatbot.ocrToggle', 'Run OCR before extracting text')}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="filled"
|
||||
leftSection={<RefreshRoundedIcon fontSize="small" />}
|
||||
loading={isStartingSession || sessionStatus === 'processing'}
|
||||
onClick={() => handleManualPrepare()}
|
||||
disabled={!selectedFile || !alphaAccepted || sessionStatus === 'processing'}
|
||||
>
|
||||
{sessionStatus === 'ready'
|
||||
? t('chatbot.refreshButton', 'Reprocess document')
|
||||
: t('chatbot.startButton', 'Prepare document for chat')}
|
||||
</Button>
|
||||
|
||||
{derivedStatusMessage && (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--bg-muted)',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
c={
|
||||
sessionStatus === 'error' || sessionStatus === 'unsupported'
|
||||
? 'var(--text-warning)'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
{derivedStatusMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Divider label={t('chatbot.conversationTitle', 'Conversation')} />
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<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 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
|
||||
</Text>
|
||||
)}
|
||||
{messages.map(renderMessageBubble)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 12,
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-toolbar)',
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
|
||||
minRows={2}
|
||||
autosize
|
||||
maxRows={6}
|
||||
value={prompt}
|
||||
maxLength={MAX_PROMPT_CHARS}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
disabled={disablePromptInput}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
/>
|
||||
<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={!canSend}
|
||||
>
|
||||
{t('chatbot.sendButton', 'Send')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotDrawer;
|
||||
@@ -20,6 +20,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useChatbot } from '@app/contexts/ChatbotContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function RightRail() {
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
const { openChat } = useChatbot();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
|
||||
@@ -65,6 +67,8 @@ export default function RightRail() {
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
|
||||
const viewerActiveFile = activeFiles[viewerContext?.activeFileIndex ?? 0];
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
||||
@@ -240,6 +244,24 @@ export default function RightRail() {
|
||||
tooltipPosition,
|
||||
tooltipOffset
|
||||
)}
|
||||
{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 className="right-rail-spacer" />
|
||||
|
||||
@@ -7,12 +7,16 @@ 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 { 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 { selectors } = useFileState();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
|
||||
@@ -136,7 +140,19 @@ 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,
|
||||
bookmarkLabel,
|
||||
tooltipPosition,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,34 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
const initialDelay = retryOptions?.initialDelay ?? 1000;
|
||||
|
||||
const fetchConfig = useCallback(async (force = false) => {
|
||||
// First check if user has a JWT token - if not, they're not authenticated
|
||||
const hasJWT = localStorage.getItem('stirling_jwt');
|
||||
|
||||
// Check if on auth page
|
||||
// Need to check for paths with or without base path
|
||||
const pathname = window.location.pathname;
|
||||
const isAuthPage = pathname.endsWith('/login') ||
|
||||
pathname.endsWith('/signup') ||
|
||||
pathname.endsWith('/auth/callback') ||
|
||||
pathname.includes('/auth/') ||
|
||||
pathname.includes('/invite/');
|
||||
|
||||
// Skip config fetch if:
|
||||
// 1. On auth page, OR
|
||||
// 2. No JWT token (not authenticated) and not forcing
|
||||
if (isAuthPage || (!hasJWT && !force)) {
|
||||
console.debug('[AppConfig] Skipping config fetch:', {
|
||||
reason: isAuthPage ? 'On auth page' : 'No JWT token',
|
||||
pathname,
|
||||
hasJWT: !!hasJWT,
|
||||
force
|
||||
});
|
||||
setLoading(false);
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate fetches unless forced
|
||||
if (!force && fetchCountRef.current > 0) {
|
||||
console.debug('[AppConfig] Already fetched, skipping');
|
||||
@@ -129,6 +157,16 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
console.log('[AppConfig] Fetching app config...');
|
||||
}
|
||||
|
||||
// GUARD: Only make the API call if user has JWT token
|
||||
const currentJWT = localStorage.getItem('stirling_jwt');
|
||||
if (!currentJWT && !force) {
|
||||
console.debug('[AppConfig] No JWT token, skipping API call entirely');
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// apiClient automatically adds JWT header if available via interceptors
|
||||
// Always suppress error toast - we handle 401 errors locally
|
||||
const response = await apiClient.get<AppConfig>(
|
||||
@@ -203,7 +241,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
if (autoFetch) {
|
||||
fetchConfig();
|
||||
}
|
||||
}, [autoFetch, fetchConfig]);
|
||||
}, [autoFetch]);
|
||||
|
||||
// Listen for JWT availability (triggered on login/signup)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { extractTextFromPdf } from '@app/services/pdfTextExtractor';
|
||||
import { extractTextFromDocx } from '@app/services/docxTextExtractor';
|
||||
import {
|
||||
ChatbotSessionInfo,
|
||||
createChatbotSession,
|
||||
} from '@app/services/chatbotService';
|
||||
import { runOcrForChat } from '@app/services/chatbotOcrService';
|
||||
|
||||
type ChatbotSource = 'viewer' | 'tool';
|
||||
|
||||
interface OpenChatOptions {
|
||||
source?: ChatbotSource;
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
type PreparationStatus = 'idle' | 'processing' | 'ready' | 'error' | 'unsupported';
|
||||
|
||||
interface PreparedChatbotDocument {
|
||||
documentId: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
status: PreparationStatus;
|
||||
session?: ChatbotSessionInfo;
|
||||
characterCount?: number;
|
||||
pageCount?: number;
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PreprocessOptions {
|
||||
force?: boolean;
|
||||
forceOcr?: boolean;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
isOpen: boolean;
|
||||
source: ChatbotSource;
|
||||
preferredFileId?: string;
|
||||
openChat: (options?: OpenChatOptions) => void;
|
||||
closeChat: () => void;
|
||||
setPreferredFileId: (fileId?: string) => void;
|
||||
sessions: Record<string, PreparedChatbotDocument>;
|
||||
requestPreprocessing: (fileId: string, options?: PreprocessOptions) => Promise<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 { selectors } = useFileState();
|
||||
const [preparedSessions, setPreparedSessions] = useState<
|
||||
Record<string, PreparedChatbotDocument>
|
||||
>({});
|
||||
const sessionsRef = useRef(preparedSessions);
|
||||
sessionsRef.current = preparedSessions;
|
||||
const inFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const supportedExtensions = useMemo(
|
||||
() => new Set(['pdf', 'doc', 'docx']),
|
||||
[]
|
||||
);
|
||||
|
||||
const getExtension = useCallback((file: StirlingFile) => {
|
||||
const parts = file.name.split('.');
|
||||
return parts.length > 1 ? parts.at(-1)!.toLowerCase() : '';
|
||||
}, []);
|
||||
|
||||
const updateSessionEntry = useCallback((file: StirlingFile, partial: Partial<PreparedChatbotDocument>) => {
|
||||
setPreparedSessions((prev) => ({
|
||||
...prev,
|
||||
[file.fileId]: {
|
||||
...prev[file.fileId],
|
||||
documentId: file.fileId,
|
||||
fileId: file.fileId,
|
||||
fileName: file.name,
|
||||
status: 'idle',
|
||||
...partial,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const preprocessFile = useCallback(
|
||||
async (file: StirlingFile, options?: PreprocessOptions) => {
|
||||
const extension = getExtension(file);
|
||||
if (!supportedExtensions.has(extension)) {
|
||||
updateSessionEntry(file, {
|
||||
status: 'unsupported',
|
||||
error: 'Only PDF and Word documents are indexed for chat.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (extension === 'doc') {
|
||||
updateSessionEntry(file, {
|
||||
status: 'unsupported',
|
||||
error: 'Legacy Word (.doc) files are not supported yet.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionEntry(file, {
|
||||
status: 'processing',
|
||||
error: undefined,
|
||||
session: undefined,
|
||||
warnings: undefined,
|
||||
characterCount: undefined,
|
||||
pageCount: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
let workingFile: File = file;
|
||||
const shouldRunOcr = Boolean(options?.forceOcr && extension === 'pdf');
|
||||
if (shouldRunOcr) {
|
||||
workingFile = await runOcrForChat(file);
|
||||
}
|
||||
let extracted: { text: string; pageCount?: number; characterCount: number };
|
||||
if (extension === 'pdf') {
|
||||
const pdfResult = await extractTextFromPdf(workingFile);
|
||||
extracted = {
|
||||
text: pdfResult.text,
|
||||
pageCount: pdfResult.pageCount,
|
||||
characterCount: pdfResult.characterCount,
|
||||
};
|
||||
} else {
|
||||
const docxResult = await extractTextFromDocx(workingFile);
|
||||
extracted = {
|
||||
text: docxResult.text,
|
||||
pageCount: 0,
|
||||
characterCount: docxResult.characterCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (!extracted.text || extracted.text.trim().length === 0) {
|
||||
throw new Error(
|
||||
'No text detected. Try running OCR from the chat window.'
|
||||
);
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
fileName: workingFile.name,
|
||||
fileSize: String(workingFile.size),
|
||||
fileType: workingFile.type || extension,
|
||||
characterCount: String(extracted.characterCount),
|
||||
ocrApplied: shouldRunOcr ? 'true' : 'false',
|
||||
};
|
||||
if (typeof extracted.pageCount === 'number') {
|
||||
metadata.pageCount = String(extracted.pageCount);
|
||||
}
|
||||
|
||||
const session = await createChatbotSession({
|
||||
sessionId: file.fileId,
|
||||
documentId: file.fileId,
|
||||
text: extracted.text,
|
||||
metadata,
|
||||
ocrRequested: shouldRunOcr,
|
||||
warningsAccepted: true,
|
||||
});
|
||||
|
||||
updateSessionEntry(file, {
|
||||
status: 'ready',
|
||||
session,
|
||||
characterCount: extracted.characterCount,
|
||||
pageCount: extracted.pageCount,
|
||||
warnings: session.warnings ?? [],
|
||||
error: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to prepare document for chatbot.';
|
||||
updateSessionEntry(file, {
|
||||
status: 'error',
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getExtension, supportedExtensions, updateSessionEntry]
|
||||
);
|
||||
|
||||
const requestPreprocessing = useCallback(
|
||||
async (fileId: string, options?: PreprocessOptions) => {
|
||||
const file = selectors.getFile(fileId as any);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (inFlightRef.current.has(fileId) && !options?.force) {
|
||||
return inFlightRef.current.get(fileId);
|
||||
}
|
||||
const promise = preprocessFile(file, options)
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(fileId);
|
||||
});
|
||||
inFlightRef.current.set(fileId, promise);
|
||||
return promise;
|
||||
},
|
||||
[selectors, preprocessFile]
|
||||
);
|
||||
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const availableFiles = useMemo(
|
||||
() => selectors.getFiles(),
|
||||
[filesSignature, selectors]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
availableFiles.forEach((file) => {
|
||||
if (!supportedExtensions.has(getExtension(file))) {
|
||||
return;
|
||||
}
|
||||
if (!sessionsRef.current[file.fileId]) {
|
||||
requestPreprocessing(file.fileId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const currentIds = new Set(availableFiles.map((file) => file.fileId));
|
||||
setPreparedSessions((prev) => {
|
||||
const next = { ...prev };
|
||||
Object.keys(next).forEach((fileId) => {
|
||||
if (!currentIds.has(fileId as any)) {
|
||||
delete next[fileId];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [availableFiles, getExtension, requestPreprocessing, supportedExtensions]);
|
||||
|
||||
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,
|
||||
sessions: preparedSessions,
|
||||
requestPreprocessing,
|
||||
}),
|
||||
[isOpen, source, preferredFileId, openChat, closeChat, preparedSessions, requestPreprocessing]
|
||||
);
|
||||
|
||||
return <ChatbotContext.Provider value={value}>{children}</ChatbotContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChatbot() {
|
||||
const context = useContext(ChatbotContext);
|
||||
if (!context) {
|
||||
throw new Error('useChatbot must be used within a ChatbotProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import SplitPdfPanel from "@app/tools/Split";
|
||||
import CompressPdfPanel from "@app/tools/Compress";
|
||||
import OCRPanel from "@app/tools/OCR";
|
||||
import ConvertPanel from "@app/tools/Convert";
|
||||
import ChatbotAssistant from "@app/tools/ChatbotAssistant";
|
||||
import Sanitize from "@app/tools/Sanitize";
|
||||
import AddPassword from "@app/tools/AddPassword";
|
||||
import ChangePermissions from "@app/tools/ChangePermissions";
|
||||
@@ -163,6 +164,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
chatbot: {
|
||||
icon: <LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t('chatbot.toolTitleMenu', 'Chatbot (Alpha)'),
|
||||
component: ChatbotAssistant,
|
||||
description: t('chatbot.toolMenuDescription', 'Chat with Stirling Bot about the contents of your PDF.'),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
maxFiles: 1,
|
||||
automationSettings: null,
|
||||
supportsAutomate: false,
|
||||
synonyms: getSynonyms(t, 'chatbot'),
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
|
||||
@@ -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() {
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
)}
|
||||
<ChatbotDrawer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const apiClient = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
xsrfCookieName: 'XSRF-TOKEN',
|
||||
xsrfHeaderName: 'X-XSRF-TOKEN',
|
||||
});
|
||||
|
||||
// Setup interceptors (core does nothing, proprietary adds JWT auth)
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||
|
||||
function readXsrfToken(): string | undefined {
|
||||
const match = document.cookie
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('XSRF-TOKEN='));
|
||||
|
||||
return match ? decodeURIComponent(match.substring('XSRF-TOKEN='.length)) : undefined;
|
||||
}
|
||||
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Add browser ID header for WAU tracking
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const browserId = getBrowserId();
|
||||
config.headers['X-Browser-Id'] = browserId;
|
||||
const token = readXsrfToken();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers['X-XSRF-TOKEN'] = token;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface ChatbotUsageSummary {
|
||||
allocatedTokens: number;
|
||||
consumedTokens: number;
|
||||
remainingTokens: number;
|
||||
usageRatio: number;
|
||||
nearingLimit: boolean;
|
||||
limitExceeded: boolean;
|
||||
lastIncrementTokens: number;
|
||||
window?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
textCharacters: number;
|
||||
estimatedTokens: number;
|
||||
warnings?: string[];
|
||||
metadata?: Record<string, string>;
|
||||
usageSummary?: ChatbotUsageSummary;
|
||||
}
|
||||
|
||||
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>;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
usageSummary?: ChatbotUsageSummary;
|
||||
}
|
||||
|
||||
export async function createChatbotSession(payload: ChatbotSessionPayload) {
|
||||
const { data } = await apiClient.post<ChatbotSessionInfo>('/api/v1/internal/chatbot/session', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendChatbotPrompt(payload: ChatbotQueryPayload) {
|
||||
const { data } = await apiClient.post<ChatbotMessageResponse>('/api/v1/internal/chatbot/query', payload);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export interface ExtractedDocxText {
|
||||
text: string;
|
||||
characterCount: number;
|
||||
}
|
||||
|
||||
export async function extractTextFromDocx(file: File): Promise<ExtractedDocxText> {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const documentXml =
|
||||
(await zip.file('word/document.xml')?.async('string')) ??
|
||||
(await zip.file('word/document2.xml')?.async('string'));
|
||||
|
||||
if (!documentXml) {
|
||||
throw new Error('Docx document.xml missing');
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(documentXml, 'application/xml');
|
||||
const paragraphNodes = [
|
||||
...Array.from(xml.getElementsByTagNameNS('*', 'p')),
|
||||
...Array.from(xml.getElementsByTagName('w:p')),
|
||||
];
|
||||
const text = paragraphNodes
|
||||
.map((p) => (p.textContent || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
text,
|
||||
characterCount: text.length,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Alert, Button, Stack, Text } from '@mantine/core';
|
||||
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useChatbot } from '@app/contexts/ChatbotContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
|
||||
const ChatbotAssistant = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openChat } = useChatbot();
|
||||
const { selectors } = useFileState();
|
||||
const files = selectors.getFiles();
|
||||
const preferredFileId = files[0]?.fileId;
|
||||
const hasAutoOpened = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAutoOpened.current) {
|
||||
openChat({ source: 'tool', fileId: preferredFileId });
|
||||
hasAutoOpened.current = true;
|
||||
}
|
||||
}, [openChat, preferredFileId]);
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="sm">
|
||||
<Alert color="yellow" icon={<WarningAmberRoundedIcon fontSize="small" />}>
|
||||
{t('chatbot.toolNotice', 'Chatbot lives inside the main workspace. Use the button below to focus the conversation pane on the left.')}
|
||||
</Alert>
|
||||
<Text>
|
||||
{t('chatbot.toolDescription', 'Ask Stirling Bot questions about any uploaded PDF. The assistant uses your extracted text, so make sure the correct document is selected inside the chat panel.')}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<SmartToyRoundedIcon fontSize="small" />}
|
||||
onClick={() => openChat({ source: 'tool', fileId: preferredFileId })}
|
||||
>
|
||||
{t('chatbot.toolOpenButton', 'Open chat window')}
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('chatbot.toolHint', 'The chat window slides in from the left. If it is already open, this button simply focuses it and passes along the currently selected PDF.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotAssistant;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
export type ToolKind = 'regular' | 'super' | 'link';
|
||||
|
||||
export const CORE_REGULAR_TOOL_IDS = [
|
||||
'chatbot',
|
||||
'certSign',
|
||||
'sign',
|
||||
'addText',
|
||||
|
||||
@@ -95,6 +95,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
console.debug('[Auth] Initializing auth...');
|
||||
|
||||
// Skip auth check if we're on auth pages
|
||||
// Need to check for paths with or without base path
|
||||
const pathname = window.location.pathname;
|
||||
const isAuthPage = pathname.endsWith('/login') ||
|
||||
pathname.endsWith('/signup') ||
|
||||
pathname.endsWith('/auth/callback') ||
|
||||
pathname.includes('/auth/') ||
|
||||
pathname.includes('/invite/');
|
||||
|
||||
if (isAuthPage) {
|
||||
console.log('[Auth] On auth page, completely skipping session check');
|
||||
console.log('[Auth] Current path:', pathname);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// GUARD: Check if JWT exists before making session call
|
||||
const hasJWT = localStorage.getItem('stirling_jwt');
|
||||
if (!hasJWT) {
|
||||
console.debug('[Auth] No JWT token found, skipping session check');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip config check entirely - let the app handle login state
|
||||
// The config will be fetched by useAppConfig when needed
|
||||
const { data, error } = await springAuth.getSession();
|
||||
@@ -127,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
initializeAuth();
|
||||
|
||||
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
||||
const handleJwtAvailable = () => {
|
||||
const handleJwtAvailable = async () => {
|
||||
console.debug('[Auth] JWT available event received, refreshing session');
|
||||
void initializeAuth();
|
||||
};
|
||||
|
||||
@@ -123,7 +123,7 @@ class SpringAuthClient {
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
|
||||
if (!token) {
|
||||
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
// console.warn('[SpringAuth] getSession: No JWT found in localStorage!');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
@@ -190,7 +190,18 @@ class SpringAuthClient {
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
// Verify it was actually saved
|
||||
const savedToken = localStorage.getItem('stirling_jwt');
|
||||
if (!savedToken) {
|
||||
console.error('[SpringAuth] CRITICAL: JWT was not saved to localStorage!');
|
||||
// Try again
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
} else if (savedToken !== token) {
|
||||
console.error('[SpringAuth] CRITICAL: Saved token differs from received token!');
|
||||
} else {
|
||||
console.log('[SpringAuth] ✓ Verified JWT is correctly saved in localStorage');
|
||||
}
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
@@ -319,19 +330,37 @@ class SpringAuthClient {
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const token = data.session.access_token;
|
||||
|
||||
// Handle different response structures - the API might return the token directly or nested
|
||||
const token = data?.session?.access_token || data?.access_token || data?.token;
|
||||
|
||||
if (!token) {
|
||||
console.error('[SpringAuth] refreshSession: No access token in response:', data);
|
||||
throw new Error('No access token received from refresh endpoint');
|
||||
}
|
||||
|
||||
// Update local storage with new token
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[SpringAuth] refreshSession: New JWT stored in localStorage');
|
||||
|
||||
// Verify it was saved
|
||||
const savedToken = localStorage.getItem('stirling_jwt');
|
||||
if (savedToken !== token) {
|
||||
console.error('[SpringAuth] CRITICAL: JWT was not properly saved during refresh!');
|
||||
} else {
|
||||
console.log('[SpringAuth] refreshSession: ✓ JWT refreshed and verified in localStorage');
|
||||
}
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
// Build session object, handling different response structures
|
||||
const expires_in = data?.session?.expires_in || data?.expires_in || 3600; // Default to 1 hour
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
user: data?.user || data?.session?.user || null,
|
||||
access_token: token,
|
||||
expires_in: data.session.expires_in,
|
||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||
expires_in: expires_in,
|
||||
expires_at: Date.now() + expires_in * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
|
||||
@@ -33,7 +33,20 @@ export default function AuthCallback() {
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
console.log('[AuthCallback] JWT stored in localStorage after OAuth');
|
||||
console.log('[AuthCallback] JWT token length:', token.length);
|
||||
|
||||
// Verify it was actually saved
|
||||
const savedToken = localStorage.getItem('stirling_jwt');
|
||||
if (!savedToken) {
|
||||
console.error('[AuthCallback] CRITICAL: JWT was not saved to localStorage!');
|
||||
// Try again
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
} else if (savedToken !== token) {
|
||||
console.error('[AuthCallback] CRITICAL: Saved token differs from received token!');
|
||||
} else {
|
||||
console.log('[AuthCallback] ✓ Verified JWT is correctly saved in localStorage');
|
||||
}
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
@@ -272,8 +272,12 @@ export default function Login() {
|
||||
setError(error.message);
|
||||
} else if (user && session) {
|
||||
console.log('[Login] Email sign in successful');
|
||||
// Auth state will update automatically and Landing will redirect to home
|
||||
// No need to navigate manually here
|
||||
// Dispatch event to trigger auth state update
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
// Navigate to home page
|
||||
setTimeout(() => {
|
||||
navigate('/', { replace: true });
|
||||
}, 100); // Small delay to ensure auth state updates
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Unexpected error:', err);
|
||||
|
||||
Reference in New Issue
Block a user