Compare commits

...
Author SHA1 Message Date
Dario Ghunney Ware 6ad4843d8e login bounce fix 2025-12-03 23:23:35 +00:00
Dario Ghunney Ware 424c5ddfa0 Streaming chunks 2025-12-03 00:16:31 +00:00
Dario Ghunney Ware 78a4c0363a resolving conflicts 2025-12-02 13:25:38 +00:00
Dario Ghunney Ware d3f4a40f68 Optimising caching and retrieval 2025-12-02 12:57:20 +00:00
DarioGii c9cd6404ae unify chat across docs 2 2025-12-02 12:56:50 +00:00
DarioGii 9e9179015e unify chat across docs 2025-12-02 12:56:50 +00:00
DarioGii 8139987919 pre-process docs on upload 2025-12-02 12:56:48 +00:00
Dario Ghunney Ware f4b39101ac improving context 2025-12-02 12:56:43 +00:00
Dario Ghunney Ware f82020a3b7 wip - implementing RAG system 2025-12-02 12:55:01 +00:00
DarioGii cb0f7c7720 UI elements 2025-12-02 12:24:14 +00:00
Dario Ghunney Ware 3695a9a70a wip - working on timeout issue 2025-12-02 12:22:39 +00:00
DarioGii 98d4949930 wip 2025-12-02 12:21:02 +00:00
Dario Ghunney Ware 6dcf20b9c9 wip - trying to connect to OpenAi 2025-12-02 12:20:59 +00:00
Dario Ghunney Ware 949e8eb2c3 Updating version, removing deprecated classes 2025-12-02 12:18:08 +00:00
Dario Ghunney Ware e0ad76eeab chatbot UI
# Conflicts:
#	frontend/public/locales/en-US/translation.json
#	frontend/src/core/components/AppProviders.tsx
#	frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
2025-12-02 12:18:04 +00:00
Dario Ghunney Ware d502e90f11 wip - resolving dependency issues 2025-12-02 12:13:53 +00:00
Dario Ghunney Ware 1325196d75 wip - building skeleton
# Conflicts:
#	app/proprietary/build.gradle
#	build.gradle
2025-12-02 12:13:53 +00:00
stirlingbot[bot] c5030a543a 🤖 format everything with pre-commit by stirlingbot (#4175)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-01 13:04:30 +00:00
Dario Ghunney Ware 58bfd8bee0 JWT Authentication (#3921)
This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF,
allowing for stateless authentication capabilities alongside the
existing session-based authentication system.

### Key Features & Changes

  JWT Authentication System
- Core Service: JwtService.java - Token generation, validation, and
cookie management
- Authentication Filter: JwtAuthenticationFilter.java - Request
interceptor for JWT validation
- Key Management: KeyPersistenceService.java +
KeyPairCleanupService.java - RSA key rotation and persistence
  - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup

  Security Integration
- SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML
request storage
- OAuth2: Updated CustomAuthenticationSuccessHandler. java,
CustomOAuth2AuthenticationSuccessHandler.java &
CustomSaml2AuthenticationSuccessHandler.java for JWT integration
- Configuration: Enhanced SecurityConfiguration.java with JWT filter
chain

  Infrastructure
  - Caching: CacheConfig.java - Caffeine cache for JWT keys
  - Database: New JwtVerificationKey.java entity for key storage
- Error Handling: JwtAuthenticationEntryPoint.java for unauthorized
access

### Challenges Encountered

- Configured SecurityConfiguration to use either
`UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter`
based on whether JWTs are enabled to prevent the former intercepting
requests while in stateless mode.
- Removed the `.defaultSuccessUrl("/")` from login configuration as its
inclusion was preventing overriding the use of the
`CustomAuthenticationSuccessHandler` and preventing proper
authentication flows.
---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [x] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="599" height="515" alt="Screenshot 2025-07-10 at 13 35 56"
src="https://github.com/user-attachments/assets/4126b752-ad0d-4ffa-b295-6714c43381e1"
/>

<img width="392" height="376" alt="Screenshot 2025-07-10 at 13 36 10"
src="https://github.com/user-attachments/assets/c681bc43-68ff-4934-8245-d544e2ad7b9c"
/>

<img width="1870" height="986" alt="eb750e8c3954fc47b2dd2e6e76ddb7d5"
src="https://github.com/user-attachments/assets/fca9b23d-b0b6-4884-8a26-98a441b641ef"
/>

<img width="1299" height="702" alt="Screenshot 2025-07-10 at 13 30 57"
src="https://github.com/user-attachments/assets/9415d8bf-fac4-4d38-8c3a-985d043d1076"
/>

### Testing (if applicable)

- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Conflicts:
#	.claude/settings.local.json
#	app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
#	app/core/src/main/resources/static/js/fetch-utils.js
#	app/core/src/main/resources/static/js/jwt-init.js
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java
2025-12-01 13:04:30 +00:00
64 changed files with 4657 additions and 37 deletions
@@ -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,62 @@ 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 boolean streamingEnabled = false;
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
@@ -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
+7 -1
View File
@@ -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'
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -0,0 +1,138 @@
package stirling.software.proprietary.controller;
import java.util.ArrayList;
import java.util.HashMap;
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.ChatbotChunkRequest;
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.ChatbotStreamingIngestionService;
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 ChatbotStreamingIngestionService streamingIngestionService;
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);
}
@PostMapping("/session/chunk")
public ResponseEntity<ChatbotSessionResponse> streamChunk(
@RequestBody ChatbotChunkRequest request) {
ChatbotSessionResponse response = streamingIngestionService.ingestChunk(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(new HashMap<>(session.getMetadata()))
.usageSummary(session.getUsageSummary())
.status(session.getStatus())
.build();
}
}
@@ -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);
}
}
@@ -0,0 +1,26 @@
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 ChatbotChunkRequest {
private String sessionId;
private String documentId;
private String userId;
private String chunkText;
private int chunkOrder;
private Map<String, String> metadata;
private boolean finalChunk;
private boolean ocrRequested;
private boolean imagesDetected;
private long totalCharactersHint;
}
@@ -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;
}
}
@@ -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) {}
@@ -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;
}
@@ -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;
}
}
@@ -0,0 +1,38 @@
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;
private ChatbotSessionStatus status;
public static String randomSessionId() {
return UUID.randomUUID().toString();
}
public Map<String, String> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -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;
}
@@ -0,0 +1,40 @@
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;
private ChatbotSessionStatus status;
public List<String> getWarnings() {
return warnings == null ? Collections.emptyList() : warnings;
}
public Map<String, String> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.model.chatbot;
public enum ChatbotSessionStatus {
PROCESSING,
READY
}
@@ -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;
}
@@ -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(
@@ -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
@@ -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)
@@ -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;
}
}
@@ -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)) + "...";
}
}
@@ -0,0 +1,645 @@
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.ChatbotSessionStatus;
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"));
if (session.getStatus() == null) {
session.setStatus(ChatbotSessionStatus.READY);
}
if (session.getStatus() == ChatbotSessionStatus.PROCESSING) {
throw new ChatbotException("Chatbot session is still processing the document.");
}
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 precomputedSummary = session.getMetadata().get("content.summary");
String contextSummary =
StringUtils.hasText(precomputedSummary)
? precomputedSummary
: 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 = resolveSnippet(chunk, 240);
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 = resolveSnippet(chunk, 400);
if (!StringUtils.hasText(snippet)) {
continue;
}
excerpts.append("[Chunk ").append(order).append("] ").append(snippet).append("\n");
}
if (!StringUtils.hasText(excerpts)) {
return "Chunks retrieved but no text excerpts available.";
}
return excerpts.toString();
}
private String resolveSnippet(Document chunk, int maxLen) {
Object snippetObj = chunk.getMetadata().get("chunkSnippet");
String snippet =
snippetObj instanceof String ? (String) snippetObj : chunk.getText();
if (!StringUtils.hasText(snippet)) {
return "(empty)";
}
String normalized = snippet.replaceAll("\\s+", " ").trim();
if (normalized.length() > maxLen) {
return normalized.substring(0, maxLen - 3) + "...";
}
return normalized;
}
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());
}
}
}
@@ -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());
}
}
@@ -0,0 +1,111 @@
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(),
chatbot.isStreamingEnabled(),
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,
boolean streamingEnabled,
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
}
}
}
@@ -0,0 +1,172 @@
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 java.util.concurrent.ConcurrentHashMap;
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.ChatbotSessionStatus;
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(new ConcurrentHashMap<>(metadata))
.ocrRequested(ocrApplied)
.imageContentDetected(imagesDetected)
.textCharacters(textCharacters)
.estimatedTokens(estimatedTokens)
.warningsAccepted(request.isWarningsAccepted())
.alphaWarningRequired(settings.alphaWarning())
.cacheKey(cacheKey)
.createdAt(Instant.now())
.status(ChatbotSessionStatus.READY)
.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=***");
}
}
@@ -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());
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,45 @@
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);
}
public Map<String, ChatbotSession> snapshot() {
return Map.copyOf(sessionStore);
}
}
@@ -0,0 +1,275 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Instant;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotChunkRequest;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotSessionResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSessionStatus;
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatbotStreamingIngestionService {
private static final int MAX_CHUNKS_PER_WINDOW = 25;
private static final long RATE_WINDOW_MS = 2_000L;
private static final long STALE_THRESHOLD_MS = Duration.ofMinutes(10).toMillis();
private static final int MAX_SUMMARY_CHARS = 4_000;
private static final int SNIPPET_CHARS = 320;
private final ChatbotFeatureProperties featureProperties;
private final ChatbotSessionRegistry sessionRegistry;
private final ChatbotCacheService cacheService;
private final VectorStore vectorStore;
private final ChatbotUsageService usageService;
private final ConcurrentHashMap<String, IngestionTracker> trackers =
new ConcurrentHashMap<>();
private final ChatbotUsageService usageService;
public ChatbotSessionResponse ingestChunk(ChatbotChunkRequest request) {
ChatbotSettings settings = featureProperties.current();
if (!settings.streamingEnabled()) {
throw new ChatbotException("Streaming ingestion is disabled in this environment.");
}
if (!StringUtils.hasText(request.getChunkText())) {
throw new ChatbotException("Chunk payload must contain text.");
}
ChatbotSession session = resolveSession(request);
processChunk(session, request);
if (request.isFinalChunk()) {
finalizeSession(session);
}
return buildResponse(session, settings);
}
private ChatbotSession resolveSession(ChatbotChunkRequest request) {
if (StringUtils.hasText(request.getSessionId())) {
return sessionRegistry
.findById(request.getSessionId())
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
}
if (!StringUtils.hasText(request.getDocumentId())) {
throw new ChatbotException("Document ID is required for the first chunk.");
}
String sessionId = UUID.randomUUID().toString();
Map<String, String> metadata =
request.getMetadata() == null
? new ConcurrentHashMap<>()
: new ConcurrentHashMap<>(request.getMetadata());
ChatbotSession session =
ChatbotSession.builder()
.sessionId(sessionId)
.documentId(request.getDocumentId())
.userId(request.getUserId())
.metadata(metadata)
.ocrRequested(request.isOcrRequested())
.imageContentDetected(request.isImagesDetected())
.warningsAccepted(true)
.alphaWarningRequired(featureProperties.current().alphaWarning())
.textCharacters(0L)
.estimatedTokens(0L)
.createdAt(Instant.now())
.status(ChatbotSessionStatus.PROCESSING)
.build();
sessionRegistry.register(session);
return session;
}
private void processChunk(ChatbotSession session, ChatbotChunkRequest request) {
ChatbotSessionStatus status = session.getStatus();
if (status == ChatbotSessionStatus.READY && !request.isFinalChunk()) {
throw new ChatbotException("Session already finalised, cannot accept more chunks.");
}
IngestionTracker tracker = trackerFor(session.getSessionId());
tracker.record(request.getChunkText());
Document chunkDocument = new Document(request.getChunkText());
Map<String, Object> docMetadata = chunkDocument.getMetadata();
docMetadata.putAll(session.getMetadata());
docMetadata.put("sessionId", session.getSessionId());
docMetadata.put("documentId", session.getDocumentId());
docMetadata.put("chunkOrder", Integer.toString(request.getChunkOrder()));
docMetadata.put("chunkSnippet", tracker.lastSnippet());
try {
vectorStore.add(List.of(chunkDocument));
} catch (RuntimeException ex) {
throw new ChatbotException(
"Failed to index streamed chunk: " + sanitize(ex.getMessage()), ex);
}
session.setTextCharacters(
session.getTextCharacters() + request.getChunkText().length());
session.setEstimatedTokens(Math.max(1L, Math.round(session.getTextCharacters() / 4.0)));
if (request.getMetadata() != null && !request.getMetadata().isEmpty()) {
session.getMetadata().putAll(request.getMetadata());
}
session.setImageContentDetected(
session.isImageContentDetected() || request.isImagesDetected());
session.setOcrRequested(session.isOcrRequested() || request.isOcrRequested());
session.getMetadata().put("content.summary", tracker.summary());
}
private void finalizeSession(ChatbotSession session) {
session.setStatus(ChatbotSessionStatus.READY);
if (!StringUtils.hasText(session.getCacheKey())) {
String cacheKey =
cacheService.register(
session.getSessionId(),
session.getDocumentId(),
new HashMap<>(session.getMetadata()),
session.isOcrRequested(),
session.isImageContentDetected(),
session.getTextCharacters());
session.setCacheKey(cacheKey);
}
session.setUsageSummary(
usageService.registerIngestion(
session.getUserId(), session.getEstimatedTokens()));
trackers.remove(session.getSessionId());
}
private ChatbotSessionResponse buildResponse(
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())
.createdAt(session.getCreatedAt())
.maxCachedCharacters(cacheService.getMaxDocumentCharacters())
.warnings(streamingWarnings(session))
.metadata(new HashMap<>(session.getMetadata()))
.usageSummary(session.getUsageSummary())
.status(session.getStatus())
.build();
}
private List<String> streamingWarnings(ChatbotSession session) {
List<String> warnings = new ArrayList<>();
if (session.isImageContentDetected()) {
warnings.add("Image content detected images are currently ignored.");
}
if (session.isOcrRequested()) {
warnings.add("OCR was requested for this session.");
}
return warnings;
}
private String sanitize(String message) {
if (!StringUtils.hasText(message)) {
return "unexpected error";
}
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
}
private IngestionTracker trackerFor(String sessionId) {
return trackers.computeIfAbsent(sessionId, id -> new IngestionTracker());
}
@Scheduled(fixedDelayString = "${chatbot.streaming.cleanup-interval:300000}")
public void cleanupStaleSessions() {
if (!featureProperties.current().streamingEnabled()) {
return;
}
long now = System.currentTimeMillis();
trackers.forEach(
(sessionId, tracker) -> {
if (now - tracker.lastUpdated() > STALE_THRESHOLD_MS) {
trackers.remove(sessionId);
sessionRegistry.remove(sessionId);
cacheService.invalidateSession(sessionId);
log.warn(
"Streaming session {} cleaned up after {} ms of inactivity",
sessionId,
now - tracker.lastUpdated());
}
});
}
private final class IngestionTracker {
private long windowStart = System.currentTimeMillis();
private int chunksInWindow = 0;
private long lastUpdated = System.currentTimeMillis();
private final StringBuilder summaryBuilder = new StringBuilder();
private String lastSnippet = "";
synchronized void record(String chunkText) {
long now = System.currentTimeMillis();
lastUpdated = now;
if (now - windowStart > RATE_WINDOW_MS) {
windowStart = now;
chunksInWindow = 0;
}
if (chunksInWindow >= MAX_CHUNKS_PER_WINDOW) {
throw new ChatbotException(
"Too many chunk uploads in a short period. Please slow down.");
}
chunksInWindow++;
lastSnippet = snippet(chunkText);
appendSummary(lastSnippet);
}
private void appendSummary(String snippet) {
if (!StringUtils.hasText(snippet)) {
return;
}
if (summaryBuilder.length() >= MAX_SUMMARY_CHARS) {
return;
}
summaryBuilder.append(snippet);
if (summaryBuilder.length() > MAX_SUMMARY_CHARS) {
summaryBuilder.setLength(MAX_SUMMARY_CHARS);
}
}
String summary() {
return summaryBuilder.toString();
}
long lastUpdated() {
return lastUpdated;
}
String lastSnippet() {
return lastSnippet;
}
private String snippet(String text) {
if (!StringUtils.hasText(text)) {
return "";
}
String normalized = text.replaceAll("\\s+", " ").trim();
if (normalized.length() > SNIPPET_CHARS) {
return normalized.substring(0, SNIPPET_CHARS - 3) + "...";
}
return normalized;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -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
@@ -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());
}
}
@@ -0,0 +1,143 @@
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,
false,
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;
}
}
@@ -0,0 +1,148 @@
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,
false,
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,
false,
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");
}
}
+4
View File
@@ -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"
}
}
+5
View File
@@ -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,282 @@
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,
uploadChatbotChunk,
} 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"),
+2
View File
@@ -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>
);
}
+2
View File
@@ -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,88 @@
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 type ChatbotSessionStatus = 'PROCESSING' | 'READY';
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;
status?: ChatbotSessionStatus;
}
export interface ChatbotQueryPayload {
sessionId: string;
prompt: string;
allowEscalation: boolean;
}
export interface ChatbotChunkPayload {
sessionId?: string;
documentId: string;
chunkText: string;
chunkOrder: number;
metadata?: Record<string, string>;
finalChunk: boolean;
ocrRequested: boolean;
imagesDetected: boolean;
totalCharactersHint?: number;
}
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 uploadChatbotChunk(payload: ChatbotChunkPayload) {
const { data } = await apiClient.post<ChatbotSessionInfo>('/api/v1/internal/chatbot/session/chunk', 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;
+1
View File
@@ -7,6 +7,7 @@ import {
export type ToolKind = 'regular' | 'super' | 'link';
export const CORE_REGULAR_TOOL_IDS = [
'chatbot',
'certSign',
'sign',
'addText',
+29 -8
View File
@@ -24,12 +24,6 @@ const AuthContext = createContext<AuthContextType>({
refreshSession: async () => {},
});
/**
* Auth Provider Component
*
* Manages authentication state and provides it to the entire app.
* Integrates with Spring Security + JWT backend.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
@@ -95,6 +89,33 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
console.debug('[Auth] Initializing auth...');
// GUARD: Check if JWT exists before making session call
const hasJWT = localStorage.getItem('stirling_jwt');
// Skip auth check if we're on auth pages *and* there is no JWT yet.
// Once a JWT exists (after login), we still want to fetch the session even if the URL
// hasn't navigated away from /login.
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 && !hasJWT) {
console.log('[Auth] On auth page without JWT, skipping session check');
console.log('[Auth] Current path:', pathname);
setLoading(false);
return;
}
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 +148,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();
};
@@ -155,7 +176,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Handle specific events
if (event === 'SIGNED_OUT') {
console.debug('[Auth] User signed out, clearing session');
console.debug('[Auth] User signed out, clearing session');
} else if (event === 'SIGNED_IN') {
console.debug('[Auth] User signed in successfully');
} else if (event === 'TOKEN_REFRESHED') {
@@ -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'));
+6 -2
View File
@@ -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);