mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Change AI engine to execute tools in Java instead of on frontend (#6116)
# Description of Changes Redesign AI engine so that it autogenerates the `tool_models.py` file from the OpenAPI spec so the Python has access to the Java API parameters and the full list of Java tools that it can run. CI ensures that whenever someone modifies a tool endpoint that the AI enigne tool models get updated as well (the dev gets told to run `task engine:tool-models`). There's loads of advantages to having the Java be the one that actually executes the tools, rather than the frontend as it was previously set up to theoretically use: - The AI gets much better descriptions of the params from the API docs - It'll be usable headless in the future so a Java daemon could run to execute ops on files in a folder without the need for the UI to run - The Java already has all the logic it needs to execute the tools - We don't need to parse the TypeScript to find the API (which is hard because the TS wasn't designed to be computer-read to extract the API) I've also hooked up the prototype frontend to ensure it's working properly, and have built it in a way that all the tool names can be translated properly, which was always an issue with previous prototypes of this. --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
EthanHealy01
parent
cc9650e7a3
commit
e5767ed58b
@@ -10,6 +10,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
import org.springframework.core.task.support.TaskExecutorAdapter;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@@ -49,11 +50,18 @@ public class AsyncConfig {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI orchestration runs on a background executor, so the incoming request's {@code
|
||||
* SecurityContext} must be propagated for downstream calls to see the authenticated user.
|
||||
* Without this, {@code JobOwnershipService} scopes job keys without a user prefix and
|
||||
* authenticated downloads fail with 403; {@code InternalApiClient} also falls back to the
|
||||
* internal-API-user key instead of the caller's.
|
||||
*/
|
||||
@Bean(name = "aiStreamExecutor")
|
||||
public Executor aiStreamExecutor() {
|
||||
TaskExecutorAdapter adapter =
|
||||
new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
|
||||
adapter.setTaskDecorator(new MDCContextTaskDecorator());
|
||||
return adapter;
|
||||
return new DelegatingSecurityContextExecutor(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-8
@@ -1,10 +1,12 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -25,8 +27,12 @@ import jakarta.validation.Valid;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
@@ -45,16 +51,30 @@ public class AiEngineController {
|
||||
private final AiWorkflowService aiWorkflowService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Executor aiStreamExecutor;
|
||||
private final TaskManager taskManager;
|
||||
private final JobOwnershipService jobOwnershipService;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
|
||||
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
|
||||
*/
|
||||
@Value("${stirling.ai.streamTimeoutMs:1800000}")
|
||||
private long streamTimeoutMs;
|
||||
|
||||
public AiEngineController(
|
||||
AiEngineClient aiEngineClient,
|
||||
AiWorkflowService aiWorkflowService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor) {
|
||||
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.aiStreamExecutor = aiStreamExecutor;
|
||||
this.taskManager = taskManager;
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@@ -70,10 +90,14 @@ public class AiEngineController {
|
||||
@Operation(
|
||||
summary = "Run an AI workflow against a PDF",
|
||||
description =
|
||||
"Accepts a PDF upload and a user message and returns an AI workflow result")
|
||||
public ResponseEntity<AiWorkflowResponse> orchestrate(
|
||||
@Valid @ModelAttribute AiWorkflowRequest request) throws IOException {
|
||||
return ResponseEntity.ok(aiWorkflowService.orchestrate(request));
|
||||
"Accepts PDF uploads and a user message and returns an AI workflow result."
|
||||
+ " When the workflow produces files, they are registered with the job"
|
||||
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
|
||||
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
|
||||
throws IOException {
|
||||
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
|
||||
registerFileResultAsJob(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/orchestrate/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -83,11 +107,23 @@ public class AiEngineController {
|
||||
"Accepts a PDF upload and a user message, returns SSE events with progress"
|
||||
+ " updates followed by the final AI workflow result")
|
||||
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
|
||||
SseEmitter emitter = new SseEmitter(180_000L);
|
||||
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
||||
|
||||
emitter.onTimeout(
|
||||
() -> {
|
||||
log.warn("SSE emitter timed out for AI orchestration stream");
|
||||
// Emit an explicit error frame so the frontend reports a timeout rather than
|
||||
// silently seeing the stream end without a result.
|
||||
log.warn(
|
||||
"SSE emitter timed out for AI orchestration stream after {} ms",
|
||||
streamTimeoutMs);
|
||||
sendEvent(
|
||||
emitter,
|
||||
"error",
|
||||
Map.of(
|
||||
"message",
|
||||
"AI workflow timed out after "
|
||||
+ (streamTimeoutMs / 1000)
|
||||
+ " seconds"));
|
||||
emitter.complete();
|
||||
});
|
||||
emitter.onError(e -> log.warn("SSE emitter error for AI orchestration stream", e));
|
||||
@@ -102,15 +138,50 @@ public class AiEngineController {
|
||||
AiWorkflowResponse result =
|
||||
aiWorkflowService.orchestrate(
|
||||
request, progress -> sendEvent(emitter, "progress", progress));
|
||||
registerFileResultAsJob(result);
|
||||
sendEvent(emitter, "result", result);
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
log.error("AI orchestration stream failed", e);
|
||||
// Emit an error frame for the frontend and then complete normally. Using
|
||||
// completeWithError here as well would double-complete the emitter - the error
|
||||
// frame already conveys the failure to the client.
|
||||
sendEvent(emitter, "error", Map.of("message", e.getMessage()));
|
||||
emitter.completeWithError(e);
|
||||
emitter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any file results produced by the workflow with {@link TaskManager} so they are
|
||||
* downloadable via {@code GET /api/v1/general/files/{fileId}}. Uses {@code
|
||||
* setMultipleFileResults} so the fileIds we registered earlier are not mangled by TaskManager's
|
||||
* ZIP auto-extract path.
|
||||
*/
|
||||
private void registerFileResultAsJob(AiWorkflowResponse result) {
|
||||
List<AiWorkflowResultFile> files = result.getResultFiles();
|
||||
if (files == null || files.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Scope the job key to the current user so the download endpoint's ownership check
|
||||
// passes when security is enabled. NoOpJobOwnershipService returns the UUID unchanged
|
||||
// when security is off.
|
||||
String jobKey =
|
||||
jobOwnershipService.createScopedJobKey(java.util.UUID.randomUUID().toString());
|
||||
taskManager.createTask(jobKey);
|
||||
List<ResultFile> jobFiles =
|
||||
files.stream()
|
||||
.map(
|
||||
f ->
|
||||
ResultFile.builder()
|
||||
.fileId(f.getFileId())
|
||||
.fileName(f.getFileName())
|
||||
.contentType(f.getContentType())
|
||||
.build())
|
||||
.toList();
|
||||
taskManager.setMultipleFileResults(jobKey, jobFiles);
|
||||
taskManager.setComplete(jobKey);
|
||||
}
|
||||
|
||||
private void sendEvent(SseEmitter emitter, String name, Object data) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON));
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public enum AiWorkflowOutcome {
|
||||
PLAN("plan"),
|
||||
NEED_CLARIFICATION("need_clarification"),
|
||||
CANNOT_DO("cannot_do"),
|
||||
DRAFT("draft"),
|
||||
TOOL_CALL("tool_call"),
|
||||
COMPLETED("completed"),
|
||||
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ public enum AiWorkflowPhase {
|
||||
ANALYZING("analyzing"),
|
||||
CALLING_ENGINE("calling_engine"),
|
||||
EXTRACTING_CONTENT("extracting_content"),
|
||||
EXECUTING_TOOL("executing_tool"),
|
||||
PROCESSING("processing");
|
||||
|
||||
private final String value;
|
||||
|
||||
+24
-1
@@ -1,15 +1,38 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AiWorkflowProgressEvent {
|
||||
private AiWorkflowPhase phase;
|
||||
private long timestamp;
|
||||
|
||||
/** The tool endpoint path being executed, for {@link AiWorkflowPhase#EXECUTING_TOOL} events. */
|
||||
private String tool;
|
||||
|
||||
/**
|
||||
* 1-based index of the current plan step, for {@link AiWorkflowPhase#EXECUTING_TOOL} events.
|
||||
*/
|
||||
private Integer stepIndex;
|
||||
|
||||
/** Total number of plan steps, for {@link AiWorkflowPhase#EXECUTING_TOOL} events. */
|
||||
private Integer stepCount;
|
||||
|
||||
public static AiWorkflowProgressEvent of(AiWorkflowPhase phase) {
|
||||
return new AiWorkflowProgressEvent(phase, System.currentTimeMillis());
|
||||
return new AiWorkflowProgressEvent(phase, System.currentTimeMillis(), null, null, null);
|
||||
}
|
||||
|
||||
public static AiWorkflowProgressEvent executingTool(String tool, int stepIndex, int stepCount) {
|
||||
return new AiWorkflowProgressEvent(
|
||||
AiWorkflowPhase.EXECUTING_TOOL,
|
||||
System.currentTimeMillis(),
|
||||
tool,
|
||||
stepIndex,
|
||||
stepCount);
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -44,6 +44,30 @@ public class AiWorkflowResponse {
|
||||
@Schema(description = "Structured tool steps when the workflow returns a plan")
|
||||
private List<Map<String, Object>> steps = new ArrayList<>();
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Tool endpoint path for tool_call outcomes (e.g. /api/v1/misc/compress-pdf)")
|
||||
private String tool;
|
||||
|
||||
@Schema(description = "Tool parameters for tool_call outcomes")
|
||||
private Map<String, Object> parameters;
|
||||
|
||||
@Schema(description = "Result file ID after tool execution completes (single-file result)")
|
||||
private String fileId;
|
||||
|
||||
@Schema(description = "Result filename after tool execution completes (single-file result)")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "Result MIME type after tool execution completes (single-file result)")
|
||||
private String contentType;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Result files produced by the workflow. Always populated on completed outcomes"
|
||||
+ " with at least one entry; for single-file results this mirrors"
|
||||
+ " fileId/fileName/contentType.")
|
||||
private List<AiWorkflowResultFile> resultFiles = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Per-file text extraction requests from the AI engine")
|
||||
private List<AiWorkflowFileRequest> files = new ArrayList<>();
|
||||
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A single file produced by a completed AI workflow. */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "Descriptor for a file produced by an AI workflow")
|
||||
public class AiWorkflowResultFile {
|
||||
|
||||
@Schema(description = "Stirling file ID — download with GET /api/v1/general/files/{fileId}")
|
||||
private String fileId;
|
||||
|
||||
@Schema(description = "Original filename for the file")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "MIME type of the file", example = "application/pdf")
|
||||
private String contentType;
|
||||
}
|
||||
+218
-2
@@ -7,16 +7,33 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.ZipExtractionUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
|
||||
@@ -24,6 +41,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowPhase;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
|
||||
@@ -39,6 +57,10 @@ public class AiWorkflowService {
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final InternalApiClient internalApiClient;
|
||||
private final FileStorage fileStorage;
|
||||
private final ToolMetadataService toolMetadataService;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressListener {
|
||||
@@ -89,12 +111,13 @@ public class AiWorkflowService {
|
||||
AiWorkflowResponse response = invokeOrchestrator(request);
|
||||
return switch (response.getOutcome()) {
|
||||
case NEED_CONTENT -> onNeedContent(response, filesByName, request, listener);
|
||||
case TOOL_CALL -> onToolCall(response, filesByName, listener);
|
||||
case PLAN -> onPlan(response, filesByName, listener);
|
||||
case ANSWER,
|
||||
NOT_FOUND,
|
||||
PLAN,
|
||||
NEED_CLARIFICATION,
|
||||
CANNOT_DO,
|
||||
TOOL_CALL,
|
||||
DRAFT,
|
||||
COMPLETED,
|
||||
UNSUPPORTED_CAPABILITY,
|
||||
CANNOT_CONTINUE ->
|
||||
@@ -174,6 +197,199 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState onToolCall(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
ProgressListener listener) {
|
||||
String endpointPath = response.getTool();
|
||||
Map<String, Object> parameters = response.getParameters();
|
||||
if (endpointPath == null || endpointPath.isBlank()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("AI engine returned tool_call without a tool endpoint."));
|
||||
}
|
||||
if (parameters == null) {
|
||||
parameters = Map.of();
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> inputFiles = toResources(filesByName);
|
||||
listener.onProgress(AiWorkflowProgressEvent.executingTool(endpointPath, 1, 1));
|
||||
List<Resource> results = executeStep(endpointPath, parameters, inputFiles);
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
results,
|
||||
new ArrayList<>(filesByName.keySet())));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute tool {}: {}", endpointPath, e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Tool execution failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private WorkflowState onPlan(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
ProgressListener listener) {
|
||||
List<Map<String, Object>> steps = response.getSteps();
|
||||
if (steps == null || steps.isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("AI engine returned a plan with no steps."));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> currentFiles = toResources(filesByName);
|
||||
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Map<String, Object> step = steps.get(i);
|
||||
String endpointPath = (String) step.get("tool");
|
||||
Map<String, Object> parameters =
|
||||
step.containsKey("parameters")
|
||||
? (Map<String, Object>) step.get("parameters")
|
||||
: Map.of();
|
||||
|
||||
if (endpointPath == null || endpointPath.isBlank()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Plan step " + (i + 1) + " has no tool endpoint."));
|
||||
}
|
||||
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(endpointPath, i + 1, steps.size()));
|
||||
currentFiles = executeStep(endpointPath, parameters, currentFiles);
|
||||
}
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getSummary(),
|
||||
currentFiles,
|
||||
new ArrayList<>(filesByName.keySet())));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to execute plan: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("Plan execution failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool step. If the endpoint accepts multiple files, all files are sent in one
|
||||
* call. Otherwise, the endpoint is called once per file. ZIP responses are unpacked so each
|
||||
* inner file is treated as its own result (e.g. split outputs a ZIP of pages).
|
||||
*/
|
||||
private List<Resource> executeStep(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> inputFiles)
|
||||
throws IOException {
|
||||
List<Resource> results = new ArrayList<>();
|
||||
if (toolMetadataService.isMultiInput(endpointPath)) {
|
||||
results.addAll(callEndpoint(endpointPath, parameters, inputFiles));
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
results.addAll(callEndpoint(endpointPath, parameters, List.of(file)));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an endpoint and return the response body. Endpoints that are declared as ZIP-returning
|
||||
* in the API spec (multi-output, or {@code Output:ZIP-*}) are unpacked into their individual
|
||||
* entries so callers always see a flat list of result files.
|
||||
*/
|
||||
private List<Resource> callEndpoint(
|
||||
String endpointPath, Map<String, Object> parameters, List<Resource> files)
|
||||
throws IOException {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
for (Resource file : files) {
|
||||
body.add("fileInput", file);
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
|
||||
if (entry.getValue() instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
body.add(entry.getKey(), item);
|
||||
}
|
||||
} else {
|
||||
body.add(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
ResponseEntity<Resource> response = internalApiClient.post(endpointPath, body);
|
||||
if (!HttpStatus.OK.equals(response.getStatusCode()) || response.getBody() == null) {
|
||||
throw new IOException(
|
||||
"Tool returned HTTP " + response.getStatusCode() + " for " + endpointPath);
|
||||
}
|
||||
Resource resource = response.getBody();
|
||||
if (toolMetadataService.shouldUnpackZipResponse(endpointPath)) {
|
||||
return ZipExtractionUtils.extractZip(resource, tempFileManager);
|
||||
}
|
||||
return List.of(resource);
|
||||
}
|
||||
|
||||
private List<Resource> toResources(Map<String, MultipartFile> filesByName) throws IOException {
|
||||
List<Resource> resources = new ArrayList<>();
|
||||
for (MultipartFile file : filesByName.values()) {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
|
||||
file.transferTo(tempFile.getPath());
|
||||
final String originalName = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
resources.add(
|
||||
new FileSystemResource(tempFile.getFile()) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return originalName;
|
||||
}
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary, List<Resource> resultFiles, List<String> inputFileNames)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
|
||||
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
|
||||
for (int i = 0; i < resultFiles.size(); i++) {
|
||||
Resource resource = resultFiles.get(i);
|
||||
String responseName = resource.getFilename();
|
||||
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
|
||||
// Prefer the input name only for 1:1 operations where the output keeps the same
|
||||
// extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// tools, the response filename from Content-Disposition is authoritative.
|
||||
String name;
|
||||
if (inputName != null
|
||||
&& FilenameUtils.getExtension(inputName)
|
||||
.equalsIgnoreCase(FilenameUtils.getExtension(responseName))) {
|
||||
name = inputName;
|
||||
} else if (responseName != null) {
|
||||
name = responseName;
|
||||
} else {
|
||||
name = "result-" + (i + 1);
|
||||
}
|
||||
String contentType =
|
||||
MediaTypeFactory.getMediaType(name)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
String fileId;
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
fileId = fileStorage.storeInputStream(is, name).fileId();
|
||||
}
|
||||
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
|
||||
}
|
||||
|
||||
AiWorkflowResponse completed = new AiWorkflowResponse();
|
||||
completed.setOutcome(AiWorkflowOutcome.COMPLETED);
|
||||
completed.setSummary(summary);
|
||||
completed.setResultFiles(descriptors);
|
||||
// Mirror the first file into the legacy single-file fields so existing clients still work.
|
||||
if (!descriptors.isEmpty()) {
|
||||
AiWorkflowResultFile first = descriptors.getFirst();
|
||||
completed.setFileId(first.getFileId());
|
||||
completed.setFileName(first.getFileName());
|
||||
completed.setContentType(first.getContentType());
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
private void validateRequest(AiWorkflowRequest request) {
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
if (fileInput.getFileInput().isEmpty()) {
|
||||
|
||||
Reference in New Issue
Block a user