Merge remote-tracking branch 'origin/main' into SaaS-update

# Conflicts:
#	frontend/editor/src/proprietary/components/chat/ChatContext.tsx
#	frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx
This commit is contained in:
James Brunton
2026-06-12 09:58:40 +01:00
59 changed files with 10405 additions and 227 deletions
@@ -112,6 +112,17 @@ public class InternalApiClient {
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
boolean hasFilePart =
body.values().stream()
.flatMap(java.util.List::stream)
.anyMatch(v -> v instanceof Resource);
if (isAiTool && !hasFilePart) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
@@ -37,8 +37,8 @@ public class LocaleConfiguration implements WebMvcConfigurer {
public LocaleResolver localeResolver() {
SessionLocaleResolver slr = new SessionLocaleResolver();
String appLocaleEnv = applicationProperties.getSystem().getDefaultLocale();
Locale defaultLocale = // Fallback to UK locale if environment variable is not set
Locale.UK;
Locale defaultLocale = // Fallback to US locale if environment variable is not set
Locale.US;
if (appLocaleEnv != null && !appLocaleEnv.isEmpty()) {
Locale tempLocale = Locale.forLanguageTag(appLocaleEnv);
String tempLanguageTag = tempLocale.toLanguageTag();
@@ -51,7 +51,7 @@ public class LocaleConfiguration implements WebMvcConfigurer {
defaultLocale = tempLocale;
} else {
System.err.println(
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-GB.");
"Invalid SYSTEM_DEFAULTLOCALE environment variable value. Falling back to default en-US.");
}
}
}
@@ -48,7 +48,7 @@ public class AdditionalLanguageJsController {
}
}
// Fallback
return "en_GB";
return "en_US";
}
""");
writer.flush();
@@ -167,7 +167,7 @@ legal:
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
system:
defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc)
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: false # see when a new update is available
@@ -23,7 +23,7 @@ class AdditionalLanguageJsControllerTest {
LanguageService lang = mock(LanguageService.class);
// LinkedHashSet for deterministic order in the array
when(lang.getSupportedLanguages())
.thenReturn(new LinkedHashSet<>(List.of("de_DE", "en_GB")));
.thenReturn(new LinkedHashSet<>(List.of("de_DE", "en_US")));
MockMvc mvc =
MockMvcBuilders.standaloneSetup(new AdditionalLanguageJsController(lang)).build();
@@ -36,9 +36,9 @@ class AdditionalLanguageJsControllerTest {
.string(
containsString(
"const supportedLanguages ="
+ " [\"de_DE\",\"en_GB\"];")))
+ " [\"de_DE\",\"en_US\"];")))
.andExpect(content().string(containsString("function getDetailedLanguageCode()")))
.andExpect(content().string(containsString("return \"en_GB\";")));
.andExpect(content().string(containsString("return \"en_US\";")));
verify(lang, times(1)).getSupportedLanguages();
}
@@ -0,0 +1,144 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
/**
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
*
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@RequiredArgsConstructor
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
* be launched at all, or it launched but immediately failed to load a required system library.
* Other conversion failures (bad HTML, output errors, etc.) return false so they surface as
* real errors rather than a misleading "dependency missing" message.
*/
private static boolean isMissingDependencyError(IOException e) {
String msg = e.getMessage();
if (msg == null) return false;
// OS could not start the process — binary not on PATH or not at the configured path.
if (msg.contains("Cannot run program")) return true;
// Process started but crashed immediately loading a shared library.
// "cannot load library" — Python/cffi error (Linux and macOS via pip)
// "Library not loaded" / "image not found" — macOS dyld error (Homebrew installs)
String lower = msg.toLowerCase();
if (lower.contains("cannot load library")) return true;
if (lower.contains("library not loaded")) return true;
if (lower.contains("image not found")) return true;
return false;
}
@PostMapping(
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
throws Exception {
log.info(
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
// SSRF: the HTML is self-contained and the engine validates style colours, so no
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
try {
ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (isMissingDependencyError(e)) {
throw new IOException(
"AI document creation is not available on this server because a required"
+ " system dependency is not installed. Please contact your"
+ " system administrator.");
}
throw e;
}
String safeFilename = Filenames.toSimpleFileName(filename);
if (safeFilename == null || safeFilename.isBlank() || !safeFilename.endsWith(".pdf")) {
safeFilename = "generated-document.pdf";
}
// Stamp the standard Stirling metadata onto the WeasyPrint output and write the result
// straight to the response temp file. Loading from the file and saving to the file
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
}
log.info(
"[create-pdf-agent] PDF ready — filename={} bytes={}",
safeFilename,
Files.size(tempOut.getPath()));
return WebResponseUtils.pdfFileToWebResponse(tempOut, safeFilename);
}
}
}
@@ -17,6 +17,7 @@ import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
@@ -175,7 +176,6 @@ public class AiWorkflowService {
initialRequest.setFiles(files);
initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -580,6 +580,10 @@ public class AiWorkflowService {
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
return new WorkflowState.Terminal(
cannotContinue(toolTimeoutMessage(e.getEndpointPath(), e)));
} catch (HttpServerErrorException e) {
String reason = extractDetailFromHttpError(e);
log.error("Plan step failed (HTTP {}): {}", e.getStatusCode(), reason);
return new WorkflowState.Terminal(cannotContinue(reason));
} catch (Exception e) {
log.error("Failed to execute plan: {}", e.getMessage(), e);
return new WorkflowState.Terminal(
@@ -605,6 +609,27 @@ public class AiWorkflowService {
return String.format("The %s tool failed: %s", endpointPath, reason);
}
/**
* Extracts the {@code detail} field from an HTTP error response body if it is valid JSON,
* otherwise falls back to the exception message. This lets controller-level error messages
* (e.g. missing system dependency) surface cleanly in the chat response.
*/
private String extractDetailFromHttpError(HttpServerErrorException e) {
try {
String body = e.getResponseBodyAsString();
if (body != null && !body.isBlank()) {
JsonNode node = objectMapper.readTree(body);
JsonNode detail = node.get("detail");
if (detail != null && detail.isTextual() && !detail.asText().isBlank()) {
return detail.asText();
}
}
} catch (Exception ignored) {
// fall through to generic message
}
return "The request could not be completed. Please try again or contact your system administrator.";
}
/**
* Adapt the AI workflow's {@link ProgressListener} to the engine's {@link
* PolicyProgressListener}: each step start maps to an {@code EXECUTING_TOOL} progress event