diff --git a/.editorconfig b/.editorconfig
index 665a74a09a..e6bda814c1 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -22,26 +22,15 @@ indent_size = 4
[*.html]
indent_size = 2
-insert_final_newline = false
-trim_trailing_whitespace = false
-[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
+[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}]
indent_size = 2
[*.css]
-# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
-# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
-indent_size = 2
-insert_final_newline = false
-trim_trailing_whitespace = false
-
-[*.json]
-# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
-[*.jsonc]
-# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
+[*.{json,jsonc}]
indent_size = 2
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index f5325c9ed7..844306824d 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -375,13 +375,13 @@ tasks:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- - npx prettier --write .
+ - npx oxfmt --write .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- - npx prettier --check .
+ - npx oxfmt --check .
fix:
desc: "Auto-fix lint and format"
@@ -554,6 +554,7 @@ tasks:
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
+ - task: format
sources:
- editor/scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
@@ -563,9 +564,9 @@ tasks:
tool-models:check:
desc: "Fail if committed tool API types are out of date"
- deps: [install, ":backend:swagger"]
cmds:
- - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
+ - task: tool-models
+ - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts
licenses:generate:
desc: "Generate frontend license report"
diff --git a/app/common/build.gradle b/app/common/build.gradle
index bb956504cc..8af68bcb76 100644
--- a/app/common/build.gradle
+++ b/app/common/build.gradle
@@ -18,7 +18,7 @@ dependencies {
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
- api 'org.snakeyaml:snakeyaml-engine:3.0.1'
+ api 'org.snakeyaml:snakeyaml-engine:3.1.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.3.2'
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
index 798de71df9..dc422433f0 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java
@@ -9,6 +9,7 @@ import java.util.Map;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
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.extern.slf4j.Slf4j;
+import stirling.software.common.model.tool.ToolFormat;
+import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
@@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode;
*
Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document against the built-in label set, and stores the engine's JSON
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
- * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
- * client use.
+ * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF.
+ *
+ *
Published in the API spec rather than hidden, so the tool-model generator emits it and a
+ * pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
+ * not a thing only the Classification policy may do.
*/
@Slf4j
-@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
@@ -99,19 +103,31 @@ public class ClassifyLabelController {
}
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ // PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step.
+ @ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF)
@Operation(
summary = "Classify a PDF and label its metadata",
description =
"Reads the first two and last two pages, classifies the document via the AI"
+ " engine, and stores the result in the StirlingPDFClassification"
- + " metadata field. Dispatched by the Classification policy; not"
- + " intended for direct client use.")
+ + " metadata field. A document that already carries a verdict is"
+ + " passed through untouched unless reclassify=true.")
public ResponseEntity classifyAndLabel(
- @RequestParam("fileInput") MultipartFile fileInput) throws IOException {
+ @RequestParam("fileInput") MultipartFile fileInput,
+ @RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify)
+ throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
+ if (!reclassify && isClassified(document)) {
+ // Classifying twice costs a second engine call and charges for it, and a document
+ // that already carries a verdict has nothing new to learn. A pipeline can run this
+ // step over a mixed batch without paying for the ones already done.
+ log.debug("[classify-and-label] {} already classified; passing through", fileName);
+ return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
+ }
+
List allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// No vocabulary to classify against: pass the file through unlabelled rather than
@@ -135,6 +151,25 @@ public class ClassifyLabelController {
}
}
+ /**
+ * Whether a verdict is already on the document.
+ *
+ * This only reads back what a previous run of this step wrote. It is not a statement that
+ * the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file
+ * can set. Skipping the engine on the strength of it is safe because the cost of being wrong is
+ * a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision
+ * from this field - routing a document somewhere on the strength of its label, say - must
+ * classify with {@code reclassify=true} rather than trust what arrived.
+ */
+ private static boolean isClassified(PDDocument document) {
+ PDDocumentInformation info = document.getDocumentInformation();
+ if (info == null) {
+ return false;
+ }
+ String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
+ return existing != null && !existing.isBlank();
+ }
+
private List extractWindow(PDDocument document) throws IOException {
List pages = new ArrayList<>();
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
index a3d43b712d..14b1eb325c 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java
@@ -85,6 +85,11 @@ public record Policy(
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
}
+ /** A copy under a different owner (e.g. moving a seed off a placeholder name). */
+ public Policy withOwner(String newOwner) {
+ return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
+ }
+
/** A copy referencing the given saved output destinations. */
public Policy withOutputIds(List newOutputIds) {
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
index 63ebae833a..9b347366bc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java
@@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
/**
- * Seeds an enabled Classification policy per team so classification is on by default. Idempotent;
- * skips the internal team.
+ * Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left
+ * unowned: nobody created it, and an owner here would have to name a real user.
*/
@Slf4j
@Component
@@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder {
private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
private static final String POLICY_NAME = "Classification Policy";
+ /**
+ * Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}.
+ */
+ private static final String LEGACY_OWNER = "system";
+
private final PolicyStore policyStore;
private final TeamRepository teamRepository;
@@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder {
.ifPresent(team -> seedIfMissing(team.getId(), team.getName()));
}
- // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own
- // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a
- // live transaction, which AFTER_COMMIT cannot offer.
+ // Seeds inside the new team's own transaction: rollback leaves no policy behind, and the
+ // store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onTeamCreated(TeamCreatedEvent event) {
seedIfMissing(event.teamId(), event.teamName());
@@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder {
if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) {
return;
}
- boolean alreadySeeded =
+ Policy existing =
policyStore.findByTeam(teamId).stream()
- .anyMatch(DefaultClassificationPolicySeeder::isClassification);
- if (alreadySeeded) {
+ .filter(DefaultClassificationPolicySeeder::isClassification)
+ .findFirst()
+ .orElse(null);
+ if (existing != null) {
+ repairOwner(existing);
return;
}
policyStore.save(defaultPolicy(teamId));
log.info("Seeded default Classification policy for team {}", teamId);
}
+ /**
+ * Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone.
+ */
+ private void repairOwner(Policy policy) {
+ if (!LEGACY_OWNER.equals(policy.owner())) {
+ return;
+ }
+ policyStore.save(policy.withOwner(null));
+ log.info(
+ "Cleared placeholder owner '{}' on Classification policy {}",
+ LEGACY_OWNER,
+ policy.id());
+ }
+
private static boolean isClassification(Policy policy) {
return policy.output() != null
&& CATEGORY.equals(policy.output().options().get("categoryId"));
@@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder {
return new Policy(
null,
POLICY_NAME,
- "system",
+ // Nobody created this - it is seeded. A name here would have to be a real user, and
+ // every consumer of owner already handles its absence.
+ null,
true,
List.of(),
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
index 74749289a7..637f05b284 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -76,7 +77,7 @@ class ClassifyLabelControllerTest {
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
try {
- controller.classifyAndLabel(file);
+ controller.classifyAndLabel(file, false);
} catch (Exception ignored) {
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and
// metadata write we assert on have already happened by the time it runs.
@@ -89,6 +90,52 @@ class ClassifyLabelControllerTest {
return objectMapper.readTree(body.getValue());
}
+ /** Stubs a document that already carries a verdict, as a second run over a batch would see. */
+ private MultipartFile alreadyClassifiedDocument() throws Exception {
+ PDDocument document = mock(PDDocument.class);
+ PDDocumentInformation info = mock(PDDocumentInformation.class);
+ when(document.getDocumentInformation()).thenReturn(info);
+ when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY))
+ .thenReturn("{\"labels\":[\"invoice\"]}");
+ MultipartFile file = mock(MultipartFile.class);
+ when(file.getOriginalFilename()).thenReturn("invoice.pdf");
+ when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
+ return file;
+ }
+
+ @Test
+ void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception {
+ withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
+ MultipartFile file = alreadyClassifiedDocument();
+
+ try {
+ controller.classifyAndLabel(file, false);
+ } catch (Exception ignored) {
+ // The response needs a real temp file; the decision under test happens before it.
+ }
+
+ // No second engine call, and no charge for one: re-classifying buys the same answer twice.
+ verify(aiEngineClient, never()).post(anyString(), anyString(), any());
+ verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString());
+ }
+
+ @Test
+ void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception {
+ withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
+ MultipartFile file = alreadyClassifiedDocument();
+ when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total");
+ when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
+ .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}");
+
+ try {
+ controller.classifyAndLabel(file, true);
+ } catch (Exception ignored) {
+ // As above.
+ }
+
+ verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull());
+ }
+
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
index 49159e7d6e..f6e82bd011 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java
@@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest {
}
private static Policy classificationPolicy(Long teamId) {
+ return classificationPolicy(teamId, null);
+ }
+
+ private static Policy classificationPolicy(Long teamId, String owner) {
return new Policy(
"p1",
"Classification Policy",
- "system",
+ owner,
true,
List.of(),
List.of(),
@@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest {
verify(policyStore, never()).save(any());
}
+ @Test
+ void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() {
+ when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system")));
+
+ seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
+
+ // "system" was never a user row, and a step dispatch authenticates as the owner. Absence
+ // is handled everywhere; a placeholder name is not.
+ ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class);
+ verify(policyStore).save(saved.capture());
+ assertThat(saved.getValue().owner()).isNull();
+ assertThat(saved.getValue().id()).isEqualTo("p1");
+ }
+
+ @Test
+ void leavesADeliberatelyChosenOwnerAlone() {
+ when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice")));
+
+ seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
+
+ verify(policyStore, never()).save(any());
+ }
+
@Test
void doesNotSeedForTheInternalTeam() {
seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal"));
diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json
new file mode 100644
index 0000000000..cb801651b8
--- /dev/null
+++ b/frontend/.oxfmtrc.json
@@ -0,0 +1,33 @@
+{
+ "printWidth": 80,
+ "tabWidth": 2,
+ "useTabs": false,
+ "endOfLine": "lf",
+ "sortPackageJson": false,
+ "ignorePatterns": [
+ "dist/",
+ "editor/dist/",
+ "editor/src-tauri/**/target/",
+ "editor/src-tauri/gen/",
+ "node_modules/",
+ "editor/public/vendor/",
+ "editor/public/mockServiceWorker.js",
+ "editor/public/og-metadata.json",
+ "editor/public/og-metadata.saas.json",
+ "editor/src/core/data/ogImageMap.json",
+ "editor/src/portal/generated/docsManifest.json",
+ "editor/public/pdfjs*/",
+ "editor/public/js/thirdParty/",
+ "editor/public/css/cookieconsent.css",
+ "storybook-static/",
+ "playwright-report/",
+ "editor/playwright-report/",
+ "test-results/",
+ "editor/test-results/",
+ "*.min.*",
+ "*.md",
+ "*.wxs",
+ "*.toml",
+ "editor/src/output.css"
+ ]
+}
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
deleted file mode 100644
index fa8ab6c502..0000000000
--- a/frontend/.prettierignore
+++ /dev/null
@@ -1,30 +0,0 @@
-dist/
-editor/dist/
-# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
-# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
-# have their own Cargo workspace under src-tauri/.
-editor/src-tauri/**/target/
-editor/src-tauri/gen/
-node_modules/
-editor/public/vendor/
-# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
-editor/public/mockServiceWorker.js
-# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
-editor/public/og-metadata.json
-editor/public/og-metadata.saas.json
-editor/src/core/data/ogImageMap.json
-# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
-editor/src/portal/generated/docsManifest.json
-editor/public/pdfjs*/
-editor/public/js/thirdParty/
-editor/public/css/cookieconsent.css
-# Build / test artifacts that may exist locally even though they're gitignored
-storybook-static/
-playwright-report/
-editor/playwright-report/
-test-results/
-editor/test-results/
-*.min.*
-*.md
-*.wxs
-editor/src/output.css
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
deleted file mode 100644
index 58bc875631..0000000000
--- a/frontend/.prettierrc
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "printWidth": 80,
- "tabWidth": 2,
- "useTabs": false,
- "endOfLine": "lf"
-}
diff --git a/frontend/README.md b/frontend/README.md
index ea759538e6..a4945ffae1 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
`frontend/` is a workspace containing one or more apps. Today it holds the
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
-`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
+`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 25544b3299..2c61890790 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -4592,6 +4592,10 @@ desc = "Change document restrictions and permissions"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
title = "Change Permissions"
+[home.classify]
+desc = "Identify what kind of document this is and tag it."
+title = "Classify"
+
[home.compare]
desc = "Compares and shows the differences between 2 PDF Documents"
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
@@ -10869,6 +10873,7 @@ searchPlaceholder = "Search tools..."
[toolPicker.subcategories]
advancedFormatting = "Advanced Formatting"
+ai = "AI"
automation = "Automation"
developerTools = "Developer Tools"
documentReview = "Document Review"
diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json
index f9733bd83f..b1eadb3bce 100644
--- a/frontend/editor/public/og-metadata.json
+++ b/frontend/editor/public/og-metadata.json
@@ -195,6 +195,11 @@
"title": "Compress - Stirling PDF",
"description": "Compress PDFs to reduce their file size."
},
+ "classify": {
+ "image": "/og_images/home.png",
+ "title": "Classify - Stirling PDF",
+ "description": "Identify what kind of document this is and tag it."
+ },
"extractPages": {
"image": "/og_images/extract-pages.png",
"title": "Extract Pages - Stirling PDF",
@@ -575,6 +580,7 @@
"/remove-cert-sign": "removeCertSign",
"/unlock-p-d-f-forms": "unlockPDFForms",
"/compress": "compress",
+ "/classify": "classify",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/extract-images": "extractImages",
diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json
index be2e7a3e92..14e30f827c 100644
--- a/frontend/editor/public/og-metadata.saas.json
+++ b/frontend/editor/public/og-metadata.saas.json
@@ -196,6 +196,11 @@
"title": "Compress - Stirling PDF",
"description": "Compress PDFs to reduce their file size."
},
+ "classify": {
+ "image": "/og_images/home.png",
+ "title": "Classify - Stirling PDF",
+ "description": "Identify what kind of document this is and tag it."
+ },
"extractPages": {
"image": "/og_images/extract-pages.png",
"title": "Extract Pages - Stirling PDF",
@@ -588,6 +593,7 @@
"/remove-cert-sign": "removeCertSign",
"/unlock-p-d-f-forms": "unlockPDFForms",
"/compress": "compress",
+ "/classify": "classify",
"/extract-pages": "extractPages",
"/reorganize-pages": "reorganizePages",
"/extract-images": "extractImages",
diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts
index 38eb7fe7b6..ec38dc37d7 100644
--- a/frontend/editor/scripts/generate-tool-api-types.mts
+++ b/frontend/editor/scripts/generate-tool-api-types.mts
@@ -10,15 +10,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { compile, type JSONSchema } from "json-schema-to-typescript";
-import * as prettier from "prettier";
-// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
-// is absent from the spec, so it cannot appear here. Extend this list when other
-// namespaces become tools.
-//
-// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
-// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
-// table, so leaving them out would stop a chain being checked past such a step.
+// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored
+// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale.
const ALLOWED_PATH_PREFIXES = [
"/api/v1/general/",
"/api/v1/misc/",
@@ -26,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [
"/api/v1/convert/",
"/api/v1/filter/",
"/api/v1/integration/",
+ "/api/v1/ai/tools/classify-and-label",
];
// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document
@@ -227,11 +222,7 @@ function collectToolIO(
return { table, dropped };
}
-async function renderToolIO(
- spec: Json,
- table: Record,
- outputPath: string,
-): Promise {
+function renderToolIO(spec: Json, table: Record): string {
if (Object.keys(table).length === 0) {
throw new Error(
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
@@ -312,33 +303,12 @@ export function toolIOFor(
}
`;
- const prettierConfig = await prettier.resolveConfig(outputPath);
- return prettier.format(body, { ...prettierConfig, parser: "typescript" });
+ return body;
}
-/** In check mode, fail when the committed file is out of date. */
-function writeOrCheck(
- outputPath: string,
- formatted: string,
- check: boolean,
- task: string,
-): void {
- if (check) {
- let current = "";
- try {
- current = readFileSync(outputPath, "utf-8");
- } catch {
- // Missing file counts as out of date.
- }
- if (current !== formatted) {
- throw new Error(
- `${outputPath} is out of date. Run '${task}' and commit the result.`,
- );
- }
- return;
- }
+function writeOutput(outputPath: string, contents: string): void {
mkdirSync(dirname(outputPath), { recursive: true });
- writeFileSync(outputPath, formatted, "utf-8");
+ writeFileSync(outputPath, contents, "utf-8");
}
async function main(): Promise {
@@ -347,12 +317,11 @@ async function main(): Promise {
spec: { type: "string" },
output: { type: "string" },
"io-output": { type: "string" },
- check: { type: "boolean", default: false },
},
});
if (!values.spec || !values.output || !values["io-output"]) {
throw new Error(
- "Usage: generate-tool-api-types.mts --spec --output --io-output [--check]",
+ "Usage: generate-tool-api-types.mts --spec --output --io-output ",
);
}
const specPath = resolve(values.spec);
@@ -478,14 +447,9 @@ async function main(): Promise {
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
);
}
- writeOrCheck(
- ioOutputPath,
- await renderToolIO(spec, ioDeclarations, ioOutputPath),
- values.check ?? false,
- "task frontend:tool-models",
- );
+ writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations));
console.log(
- `${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
+ `Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
);
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
@@ -508,7 +472,6 @@ async function main(): Promise {
definitions,
fileFieldsByClass,
outputPath,
- values.check ?? false,
skipped,
);
}
@@ -518,7 +481,6 @@ async function compileAndWrite(
definitions: Record,
fileFieldsByClass: Record,
outputPath: string,
- check: boolean,
skipped: string[],
): Promise {
// json-schema-to-typescript only emits a named, exported interface per schema
@@ -597,17 +559,9 @@ async function compileAndWrite(
].join("\n");
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
- const prettierConfig = await prettier.resolveConfig(outputPath);
- const formatted = await prettier.format(body, {
- ...prettierConfig,
- parser: "typescript",
- });
-
- writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
- console.log(
- `${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
- );
- if (!check && skipped.length > 0) {
+ writeOutput(outputPath, body);
+ console.log(`Generated ${tools.length} tool endpoints.`);
+ if (skipped.length > 0) {
console.log(
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
);
diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.tsx
index e210d2f201..f68a154e89 100644
--- a/frontend/editor/src/core/components/shared/BulkShareModal.tsx
+++ b/frontend/editor/src/core/components/shared/BulkShareModal.tsx
@@ -153,7 +153,7 @@ const BulkShareModal: React.FC = ({
if (onShared) {
await onShared();
}
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
diff --git a/frontend/editor/src/core/components/shared/CardSelector.tsx b/frontend/editor/src/core/components/shared/CardSelector.tsx
index 75e9071d81..79a94e7b40 100644
--- a/frontend/editor/src/core/components/shared/CardSelector.tsx
+++ b/frontend/editor/src/core/components/shared/CardSelector.tsx
@@ -1,5 +1,6 @@
import { Stack, Card, Text, Flex } from "@mantine/core";
import { Tooltip } from "@app/components/shared/Tooltip";
+import { TooltipTip } from "@app/types/tips";
import { useTranslation } from "react-i18next";
export interface CardOption {
@@ -7,14 +8,14 @@ export interface CardOption {
prefixKey: string;
nameKey: string;
tooltipKey?: string;
- tooltipContent?: any[];
+ tooltipContent?: TooltipTip[];
}
export interface CardSelectorProps> {
options: K[];
onSelect: (value: T) => void;
disabled?: boolean;
- getTooltipContent?: (option: K) => any[];
+ getTooltipContent?: (option: K) => TooltipTip[];
}
const CardSelector = >({
diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx
index 17502ee9b6..2b84105ba8 100644
--- a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx
+++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx
@@ -269,7 +269,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
- } as any,
+ },
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
@@ -287,7 +287,7 @@ export function FileSelectorPicker({
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
- } as any,
+ },
);
const ct = readResponseHeader(res.headers, "content-type");
const disp = readResponseHeader(res.headers, "content-disposition");
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx
index 3979a40041..f9337bd880 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx
@@ -208,7 +208,7 @@ const FileSidebar = forwardRef(
const openWatchedFolders = useCallback(() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null });
- navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
+ navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
}, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]);
// Clicking a file's membership dot jumps straight into that folder.
@@ -216,7 +216,7 @@ const FileSidebar = forwardRef(
(folderId: string) => {
if (collapsed && onToggleCollapse) onToggleCollapse();
setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId });
- navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any);
+ navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID);
},
[collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions],
);
diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
index be91ccc03d..8d2e6a7c67 100644
--- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
+++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import axios from "axios";
import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
@@ -95,10 +96,13 @@ export default function FirstLoginModal({
setTimeout(() => {
onPasswordChanged();
}, 1500);
- } catch (err: any) {
+ } catch (err: unknown) {
console.error("Failed to change password:", err);
+ const message = axios.isAxiosError<{ message?: string }>(err)
+ ? err.response?.data?.message
+ : undefined;
setError(
- err.response?.data?.message ||
+ message ||
t(
"firstLogin.passwordChangeFailed",
"Failed to change password. Please check your current password.",
diff --git a/frontend/editor/src/core/components/shared/FitText.tsx b/frontend/editor/src/core/components/shared/FitText.tsx
index c3a2ead2f2..97afc8fdb9 100644
--- a/frontend/editor/src/core/components/shared/FitText.tsx
+++ b/frontend/editor/src/core/components/shared/FitText.tsx
@@ -1,4 +1,4 @@
-import React, { CSSProperties, useMemo, useRef } from "react";
+import React, { CSSProperties, useCallback, useMemo, useRef } from "react";
import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit";
type FitTextProps = {
@@ -28,8 +28,14 @@ const FitText: React.FC = ({
}) => {
const ref = useRef(null);
+ // Callback ref: an HTMLElement handler satisfies span's/div's differing ref
+ // types (callback refs are contravariant), so the tag can stay polymorphic.
+ const setRef = useCallback((node: HTMLElement | null) => {
+ ref.current = node;
+ }, []);
+
// Hook runs after mount and on size/text changes; uses observers internally
- useAdjustFontSizeToFit(ref as any, {
+ useAdjustFontSizeToFit(ref, {
maxFontSizePx: fontSize,
minFontScale: minimumFontScale,
maxLines: lines,
@@ -38,7 +44,7 @@ const FitText: React.FC = ({
// Memoize the HTML tag to render (span/div) from the `as` prop so
// React doesn't create a new component function on each render.
- const ElementTag: any = useMemo(() => as, [as]);
+ const ElementTag: React.ElementType = useMemo(() => as, [as]);
// For the / character, insert zero-width soft breaks to prefer wrapping at them
const displayText = useMemo(() => {
@@ -70,7 +76,7 @@ const FitText: React.FC = ({
return (
diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.tsx
index 1430f9df20..009345c274 100644
--- a/frontend/editor/src/core/components/shared/ShareFileModal.tsx
+++ b/frontend/editor/src/core/components/shared/ShareFileModal.tsx
@@ -165,7 +165,7 @@ const ShareFileModal: React.FC = ({
if (onUploaded) {
await onUploaded();
}
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Failed to generate share link:", error);
setErrorMessage(
t(
diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx
index e0e5899f8d..64760da8d7 100644
--- a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx
+++ b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx
@@ -226,7 +226,7 @@ const ShareManagementModal: React.FC = ({
durationMs: 2500,
});
}
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Failed to create share link:", error);
setErrorMessage(
t(
diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx
index 5403940751..55c06a1533 100644
--- a/frontend/editor/src/core/components/shared/Tooltip.tsx
+++ b/frontend/editor/src/core/components/shared/Tooltip.tsx
@@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets";
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
+// The wrapped child's own event handlers, which Tooltip forwards to after
+// running its own trigger logic. Kept partial since any given child may set none.
+interface ForwardedHandlers {
+ onPointerEnter?: React.PointerEventHandler;
+ onPointerLeave?: React.PointerEventHandler;
+ onMouseDown?: React.MouseEventHandler;
+ onMouseUp?: React.MouseEventHandler;
+ onClick?: React.MouseEventHandler;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+}
+
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: "right" | "left" | "top" | "bottom";
@@ -218,7 +230,7 @@ export const Tooltip: React.FC = ({
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
- (children.props as any)?.onPointerEnter?.(e);
+ (children.props as ForwardedHandlers).onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled],
);
@@ -233,19 +245,19 @@ export const Tooltip: React.FC = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
- (children.props as any)?.onPointerLeave?.(e);
+ (children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
- (children.props as any)?.onPointerLeave?.(e);
+ (children.props as ForwardedHandlers).onPointerLeave?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
- (children.props as any)?.onPointerLeave?.(e);
+ (children.props as ForwardedHandlers).onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props, allowAutoClose],
);
@@ -253,7 +265,7 @@ export const Tooltip: React.FC = ({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
- (children.props as any)?.onMouseDown?.(e);
+ (children.props as ForwardedHandlers).onMouseDown?.(e);
},
[children.props],
);
@@ -262,7 +274,7 @@ export const Tooltip: React.FC = ({
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
- (children.props as any)?.onMouseUp?.(e);
+ (children.props as ForwardedHandlers).onMouseUp?.(e);
},
[children.props],
);
@@ -279,7 +291,7 @@ export const Tooltip: React.FC = ({
return;
}
clickPendingRef.current = false;
- (children.props as any)?.onClick?.(e);
+ (children.props as ForwardedHandlers).onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props],
);
@@ -288,7 +300,7 @@ export const Tooltip: React.FC = ({
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
- (children.props as any)?.onFocus?.(e);
+ (children.props as ForwardedHandlers).onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus],
);
@@ -301,12 +313,12 @@ export const Tooltip: React.FC = ({
tooltipRef.current &&
tooltipRef.current.contains(related)
) {
- (children.props as any)?.onBlur?.(e);
+ (children.props as ForwardedHandlers).onBlur?.(e);
return;
}
clearTimers();
if (allowAutoClose && !isPinned) setOpen(false);
- (children.props as any)?.onBlur?.(e);
+ (children.props as ForwardedHandlers).onBlur?.(e);
},
[isPinned, setOpen, children.props, allowAutoClose, clearTimers],
);
@@ -339,24 +351,30 @@ export const Tooltip: React.FC = ({
);
// Enhance child with handlers and ref
- const childWithHandlers = React.cloneElement(children as any, {
- ref: (node: HTMLElement | null) => {
- triggerRef.current = node || null;
- const originalRef = (children as any).ref;
- if (typeof originalRef === "function") originalRef(node);
- else if (originalRef && typeof originalRef === "object")
- (originalRef as any).current = node;
+ const childWithHandlers = React.cloneElement(
+ children as React.ReactElement>,
+ {
+ ref: (node: HTMLElement | null) => {
+ triggerRef.current = node || null;
+ const originalRef = (
+ children as React.ReactElement & { ref?: React.Ref }
+ ).ref;
+ if (typeof originalRef === "function") originalRef(node);
+ else if (originalRef && typeof originalRef === "object")
+ (originalRef as React.MutableRefObject).current =
+ node;
+ },
+ "aria-describedby": open ? tooltipIdRef.current : undefined,
+ onPointerEnter: handlePointerEnter,
+ onPointerLeave: handlePointerLeave,
+ onMouseDown: handleMouseDown,
+ onMouseUp: handleMouseUp,
+ onClick: handleClick,
+ onFocus: handleFocus,
+ onBlur: handleBlur,
+ onKeyDown: handleKeyDown,
},
- "aria-describedby": open ? tooltipIdRef.current : undefined,
- onPointerEnter: handlePointerEnter,
- onPointerLeave: handlePointerLeave,
- onMouseDown: handleMouseDown,
- onMouseUp: handleMouseUp,
- onClick: handleClick,
- onFocus: handleFocus,
- onBlur: handleBlur,
- onKeyDown: handleKeyDown,
- });
+ );
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
index 65c50a3481..0e4926adbe 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
@@ -82,7 +82,7 @@ export default function AutomationCreation({
setConfigModalOpen(true);
};
- const handleToolConfigSave = (parameters: Record) => {
+ const handleToolConfigSave = (parameters: Record) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
index 8ffbfeda81..a4a7c1d2ca 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx
@@ -19,7 +19,7 @@ interface AutomationEntryProps {
/** Optional description for tooltip */
description?: string;
/** MUI Icon component for the badge */
- badgeIcon?: React.ComponentType;
+ badgeIcon?: React.ComponentType;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
index 2e3e5f1539..675372c12b 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx
@@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "@app/types/automation";
import { EXECUTION_STATUS } from "@app/constants/automation";
import { useResourceCleanup } from "@app/utils/resourceManager";
+import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
- automateOperation?: any; // TODO: Type this properly when available
+ automateOperation?: ReturnType;
}
export default function AutomationRun({
@@ -34,13 +35,13 @@ export default function AutomationRun({
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults =
- automateOperation?.files.length > 0 ||
+ (automateOperation?.files.length ?? 0) > 0 ||
automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
useEffect(() => {
if (automation?.operations) {
- const steps = automation.operations.map((op: any, index: number) => {
+ const steps = automation.operations.map((op, index) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
return {
id: `${op.operation}-${index}`,
@@ -125,7 +126,7 @@ export default function AutomationRun({
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
- } catch (error: any) {
+ } catch (error: unknown) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
index ad7bb33a6e..2dd482863d 100644
--- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
+++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx
@@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core";
import {
ToolRegistryEntry,
ToolRegistry,
+ SubcategoryId,
getToolSupportsAutomate,
} from "@app/data/toolsTaxonomy";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -81,9 +82,7 @@ export default function ToolSelector({
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
- const { sections, searchGroups } = useToolSections(
- transformedFilteredTools as any /* FIX ME */,
- );
+ const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -98,7 +97,9 @@ export default function ToolSelector({
return [
{
name: "Tools",
- subcategoryId: "all" as any,
+ // Synthetic "all tools" group used only as a fallback when the
+ // taxonomy produces no sections; "all" is not a real SubcategoryId.
+ subcategoryId: "all" as unknown as SubcategoryId,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })),
},
];
@@ -125,7 +126,7 @@ export default function ToolSelector({
displayGroups.map((subcategory) =>
renderToolButtons(
t,
- subcategory as any,
+ subcategory,
null,
handleToolSelect,
!isSearching,
diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
index 187a48ad16..80feb0f91e 100644
--- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
+++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx
@@ -13,6 +13,7 @@ import {
useFileActions,
} from "@app/contexts/FileContext";
import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
+import { ZoomMode } from "@embedpdf/plugin-zoom/react";
import { useViewer } from "@app/contexts/ViewerContext";
import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF";
import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar";
@@ -418,7 +419,7 @@ const EmbedPdfViewerContent = ({
return;
case "0":
event.preventDefault();
- zoomActions.requestZoom("fit-width");
+ zoomActions.requestZoom(ZoomMode.FitWidth);
return;
}
}
diff --git a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx
index 36d0c59cf7..3efb250de1 100644
--- a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx
+++ b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx
@@ -118,7 +118,6 @@ export function SelectionAPIBridge() {
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
- getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
selectAll: async (totalPages: number) => {
const docId = activeDocumentId;
diff --git a/frontend/editor/src/core/contexts/viewer/viewerActions.ts b/frontend/editor/src/core/contexts/viewer/viewerActions.ts
index 2e0231d766..ae5679c9b6 100644
--- a/frontend/editor/src/core/contexts/viewer/viewerActions.ts
+++ b/frontend/editor/src/core/contexts/viewer/viewerActions.ts
@@ -6,6 +6,8 @@ import {
ZoomState,
} from "@app/contexts/viewer/viewerBridges";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
+import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
+import { FormattedSelection } from "@embedpdf/plugin-selection";
export interface ScrollActions {
scrollToPage: (page: number, behavior?: "smooth" | "instant") => void;
@@ -19,7 +21,7 @@ export interface ZoomActions {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
- requestZoom: (level: any, center?: any) => void;
+ requestZoom: (level: ZoomLevel, center?: Point) => void;
setZoomLevel: (factor: number) => void;
}
@@ -31,8 +33,7 @@ export interface PanActions {
export interface SelectionActions {
copyToClipboard: () => void;
- getSelectedText: () => string;
- getFormattedSelection: () => any;
+ getFormattedSelection: () => FormattedSelection[] | null;
selectAll: (totalPages: number) => Promise;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -51,7 +52,7 @@ export interface RotationActions {
}
export interface SearchActions {
- search: (query: string) => Promise | undefined;
+ search: (query: string) => Promise | undefined;
next: () => void;
previous: () => void;
clear: () => void;
@@ -214,7 +215,7 @@ export function createViewerActions({
api.toggleMarqueeZoom();
}
},
- requestZoom: (level: any, center?: any) => {
+ requestZoom: (level: ZoomLevel, center?: Point) => {
const api = registry.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level, center);
@@ -257,13 +258,6 @@ export function createViewerActions({
api.copyToClipboard();
}
},
- getSelectedText: () => {
- const api = registry.current.selection?.api;
- if (api?.getSelectedText) {
- return api.getSelectedText() ?? "";
- }
- return "";
- },
getFormattedSelection: () => {
const api = registry.current.selection?.api;
if (api?.getFormattedSelection) {
diff --git a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts
index c003013cb3..7a9f177e03 100644
--- a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts
+++ b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts
@@ -1,5 +1,7 @@
import { SpreadMode } from "@embedpdf/plugin-spread/react";
import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models";
+import { ZoomLevel, Point } from "@embedpdf/plugin-zoom";
+import { FormattedSelection } from "@embedpdf/plugin-selection";
export enum PdfPermissionFlag {
Print = 0x0004,
@@ -46,7 +48,7 @@ export interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
- requestZoom: (level: any, center?: any) => void;
+ requestZoom: (level: ZoomLevel, center?: Point) => void;
}
export interface PanAPIWrapper {
@@ -58,8 +60,7 @@ export interface PanAPIWrapper {
export interface SelectionAPIWrapper {
copyToClipboard: () => void;
- getSelectedText: () => string | any;
- getFormattedSelection: () => any;
+ getFormattedSelection: () => FormattedSelection[];
selectAll: (totalPages: number) => Promise;
selectWordAt: (pageIndex: number, x: number, y: number) => boolean;
}
@@ -79,7 +80,7 @@ export interface RotationAPIWrapper {
}
export interface SearchAPIWrapper {
- search: (query: string) => Promise;
+ search: (query: string) => Promise;
clear: () => void;
next: () => void;
previous: () => void;
diff --git a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx
new file mode 100644
index 0000000000..f5281f80ae
--- /dev/null
+++ b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * Classification is a thing a pipeline can do, not a thing only the Classification policy may do.
+ *
+ * The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a
+ * member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by
+ * the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never
+ * reached the spec at all. These tests pin each link, because any one of them silently removes the
+ * step from the builder's picker rather than failing loudly.
+ */
+import { describe, expect, test, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
+import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation";
+import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping";
+import { TOOL_IO } from "@app/types/toolIO";
+import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ i18n: { changeLanguage: vi.fn(), language: "en-US" },
+ }),
+ Trans: ({ children }: { children?: unknown }) => children,
+}));
+
+const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
+
+describe("classify as a pipeline task", () => {
+ test("the classify endpoint is a generated ToolEndpoint", () => {
+ // Fails if the controller goes back to @Hidden, or the generator's allowlist drops the
+ // /api/v1/ai/tools/ namespace, or nobody regenerated after either.
+ expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true);
+ });
+
+ test("the builder offers it as a step", () => {
+ const { result } = renderHook(() => useTranslatedToolCatalog());
+
+ const executable = getExecutableTools(result.current.regularTools);
+ const classify = executable.find((tool) => tool.toolId === "classify");
+
+ expect(classify).toBeDefined();
+ expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT);
+ });
+
+ test("it declares PDF in, PDF out, so a chain can be checked across it", () => {
+ // Without this the builder shows "Can't check what this step accepts" and validation stops
+ // dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec.
+ expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({
+ accepts: ["PDF"],
+ produces: "PDF",
+ arity: "SISO",
+ });
+ });
+
+ test("it is offered to pipelines but kept out of the editor's tool list", () => {
+ const { result } = renderHook(() => useTranslatedToolCatalog());
+
+ // There is no interactive classify tool to open - it only means something inside a pipeline.
+ expect(result.current.regularTools.classify?.hiddenFromToolList).toBe(true);
+ expect(
+ filterToolRegistryByQuery(result.current.regularTools, "").some(
+ (ranked) => ranked.item[0] === "classify",
+ ),
+ ).toBe(false);
+ });
+
+ test("it does not re-classify by default", () => {
+ const { result } = renderHook(() => useTranslatedToolCatalog());
+
+ const config = result.current.regularTools.classify?.operationConfig;
+
+ // The step is idempotent unless asked otherwise: a second run on a classified document
+ // would be a second engine call, and a second charge, for the same answer.
+ expect(config?.defaultParameters).toEqual({ reclassify: false });
+ });
+});
diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts
index f9c2dde02f..740b8a13ec 100644
--- a/frontend/editor/src/core/data/toolsTaxonomy.ts
+++ b/frontend/editor/src/core/data/toolsTaxonomy.ts
@@ -29,6 +29,7 @@ import { ProprietaryToolId } from "@app/types/proprietaryToolId";
import { PrototypeToolId } from "@app/types/prototypeToolId";
export enum SubcategoryId {
+ AI = "ai",
SIGNING = "signing",
DOCUMENT_SECURITY = "documentSecurity",
VERIFICATION = "verification",
@@ -71,6 +72,8 @@ export type ToolRegistryEntry = {
> | null;
// Whether this tool supports automation (defaults to true)
supportsAutomate?: boolean;
+ // Keep out of the editor's tool list: a step only a pipeline runs, with no UI to open.
+ hiddenFromToolList?: boolean;
// Synonyms for search (optional)
synonyms?: string[];
// Version status indicator (e.g., "alpha", "beta")
@@ -90,6 +93,8 @@ export type ProprietaryToolRegistry = Record<
export type PrototypeToolRegistry = Record;
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
+ // First: AI steps are the ones a user is least likely to know exist.
+ SubcategoryId.AI,
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
@@ -104,6 +109,7 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [
];
export const SUBCATEGORY_COLOR_MAP: Record = {
+ [SubcategoryId.AI]: "var(--category-color-automation)", // Pink
[SubcategoryId.SIGNING]: "var(--category-color-signing)", // Green
[SubcategoryId.DOCUMENT_SECURITY]: "var(--category-color-security)", // Orange
[SubcategoryId.VERIFICATION]: "var(--category-color-verification)", // Orange
diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
index 5ae8075d0f..5e81427d55 100644
--- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
@@ -20,6 +20,7 @@ import {
import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
import { getSynonyms } from "@app/utils/toolSynonyms";
import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry";
+import { classifyOperationConfig } from "@app/hooks/tools/classify/useClassifyOperation";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
@@ -1338,6 +1339,30 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
},
+ classify: {
+ icon: (
+
+ ),
+ name: t("home.classify.title", "Classify"),
+ // No interactive UI: this is a pipeline step, registered so a pipeline can name it.
+ component: null,
+ description: t(
+ "home.classify.desc",
+ "Identify what kind of document this is and tag it.",
+ ),
+ categoryId: ToolCategoryId.ADVANCED_TOOLS,
+ subcategoryId: SubcategoryId.AI,
+ maxFiles: -1,
+ endpoints: ["classify-and-label"],
+ operationConfig: asRegistryConfig(classifyOperationConfig),
+ automationSettings: null,
+ // Pipeline-only: there is no interactive classify tool to open in the editor.
+ hiddenFromToolList: true,
+ },
compress: {
icon: (
The tool reads a window of the document, asks the AI engine what kind of document it is, and
+ * writes the verdict into the PDF's metadata. It has no interactive UI - there is nothing for a
+ * user to set beyond whether to redo work already done - so it exists in the registry purely so a
+ * pipeline can name it, the same way the Classification policy always could.
+ */
+const ENDPOINT = "/api/v1/ai/tools/classify-and-label" satisfies ToolEndpoint;
+type ClassifyApiParams = ToolApiParams[typeof ENDPOINT];
+
+export interface ClassifyParameters {
+ /**
+ * Classify again even when the document already carries a verdict. Off by default: re-running
+ * costs a second engine call, and a document that has been classified has nothing new to say.
+ */
+ reclassify: boolean;
+}
+
+export const defaultParameters: ClassifyParameters = { reclassify: false };
+
+export const classifyToApiParams = (
+ parameters: ClassifyParameters,
+): ClassifyApiParams => ({ reclassify: parameters.reclassify });
+
+export const classifyFromApiParams = (
+ apiParams: ClassifyApiParams,
+): Partial => ({
+ reclassify: apiParams.reclassify ?? defaultParameters.reclassify,
+});
+
+export const buildClassifyFormData = (
+ parameters: ClassifyParameters,
+ file: File,
+): FormData =>
+ objectToFormData(classifyToApiParams(parameters), { fileInput: file });
+
+export const classifyOperationConfig = defineSingleFileTool({
+ buildFormData: buildClassifyFormData,
+ toApiParams: classifyToApiParams,
+ fromApiParams: classifyFromApiParams,
+ operationType: "classify",
+ endpoint: ENDPOINT,
+ defaultParameters,
+});
diff --git a/frontend/editor/src/core/services/apiClientConfig.ts b/frontend/editor/src/core/services/apiClientConfig.ts
index b476d1348a..89fa465743 100644
--- a/frontend/editor/src/core/services/apiClientConfig.ts
+++ b/frontend/editor/src/core/services/apiClientConfig.ts
@@ -11,11 +11,11 @@
*/
export function getApiBaseUrl(): string {
// Runtime override to fix hardcoded localhost in builds
- if (
- typeof window !== "undefined" &&
- (window as any).STIRLING_PDF_API_BASE_URL
- ) {
- return (window as any).STIRLING_PDF_API_BASE_URL;
+ if (typeof window !== "undefined") {
+ const override = window.STIRLING_PDF_API_BASE_URL;
+ if (override) {
+ return override;
+ }
}
return import.meta.env.VITE_API_BASE_URL;
diff --git a/frontend/editor/src/core/services/auditService.ts b/frontend/editor/src/core/services/auditService.ts
index c748504c54..acf43ab1d5 100644
--- a/frontend/editor/src/core/services/auditService.ts
+++ b/frontend/editor/src/core/services/auditService.ts
@@ -17,7 +17,7 @@ export interface AuditEvent {
eventType: string;
username: string;
ipAddress: string;
- details: Record;
+ details: Record;
}
export interface AuditEventsResponse {
diff --git a/frontend/editor/src/core/services/automationStorage.ts b/frontend/editor/src/core/services/automationStorage.ts
index 826bf3ce30..196596de18 100644
--- a/frontend/editor/src/core/services/automationStorage.ts
+++ b/frontend/editor/src/core/services/automationStorage.ts
@@ -8,7 +8,7 @@ export interface AutomationConfig {
description?: string;
operations: Array<{
operation: string;
- parameters: any;
+ parameters: Record;
}>;
createdAt: string;
updatedAt: string;
diff --git a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts
index e91c34cde2..a63651e193 100644
--- a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts
+++ b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts
@@ -5,6 +5,7 @@ import {
ProcessingConfig,
ProcessingMetrics,
} from "@app/types/processing";
+import type { PDFPageProxy } from "pdfjs-dist";
import { ProcessingCache } from "@app/services/processingCache";
import { FileHasher } from "@app/utils/fileHash";
import { FileAnalyzer } from "@app/services/fileAnalyzer";
@@ -415,7 +416,7 @@ export class EnhancedPDFProcessingService {
* Render a page thumbnail with specified quality
*/
private async renderPageThumbnail(
- page: any,
+ page: PDFPageProxy,
quality: "low" | "medium" | "high",
): Promise {
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
@@ -431,7 +432,7 @@ export class EnhancedPDFProcessingService {
throw new Error("Could not get canvas context");
}
- await page.render({ canvasContext: context, viewport }).promise;
+ await page.render({ canvasContext: context, viewport, canvas }).promise;
return canvas.toDataURL("image/jpeg", 0.8); // Use JPEG for better compression
}
diff --git a/frontend/editor/src/core/services/errorUtils.ts b/frontend/editor/src/core/services/errorUtils.ts
index cdfeb9f38f..747cd90b5a 100644
--- a/frontend/editor/src/core/services/errorUtils.ts
+++ b/frontend/editor/src/core/services/errorUtils.ts
@@ -5,7 +5,7 @@ export const FILE_EVENTS = {
const UUID_REGEX =
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
-export function tryParseJson(input: unknown): T | undefined {
+export function tryParseJson(input: unknown): T | undefined {
if (typeof input !== "string") return input as T | undefined;
try {
return JSON.parse(input) as T;
@@ -14,19 +14,20 @@ export function tryParseJson(input: unknown): T | undefined {
}
}
-export async function normalizeAxiosErrorData(data: any): Promise {
+export async function normalizeAxiosErrorData(data: unknown): Promise {
if (!data) return undefined;
- if (typeof data?.text === "function") {
- const text = await data.text();
+ const blobLike = data as { text?: () => Promise };
+ if (typeof blobLike.text === "function") {
+ const text = await blobLike.text();
return tryParseJson(text) ?? text;
}
return data;
}
-export function extractErrorFileIds(payload: any): string[] | undefined {
+export function extractErrorFileIds(payload: unknown): string[] | undefined {
if (!payload) return undefined;
- if (Array.isArray(payload?.errorFileIds))
- return payload.errorFileIds as string[];
+ const errorFileIds = (payload as { errorFileIds?: unknown }).errorFileIds;
+ if (Array.isArray(errorFileIds)) return errorFileIds as string[];
if (typeof payload === "string") {
const matches = payload.match(UUID_REGEX);
if (matches && matches.length > 0) return Array.from(new Set(matches));
@@ -45,11 +46,11 @@ export function isZeroByte(
file: File | { size?: number } | null | undefined,
): boolean {
if (!file) return true;
- const size = (file as any).size;
+ const size = file.size;
return typeof size === "number" ? size <= 0 : true;
}
export function isEmptyOutput(files: File[] | null | undefined): boolean {
if (!files || files.length === 0) return true;
- return files.every((f) => (f as any)?.size === 0);
+ return files.every((f) => f?.size === 0);
}
diff --git a/frontend/editor/src/core/services/fileStorage.migration.test.ts b/frontend/editor/src/core/services/fileStorage.migration.test.ts
index 472d83b49b..28ee69e8da 100644
--- a/frontend/editor/src/core/services/fileStorage.migration.test.ts
+++ b/frontend/editor/src/core/services/fileStorage.migration.test.ts
@@ -33,7 +33,7 @@ describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", (
it("flags a legacy versioned edit (has tool history)", () => {
expect(
legacyDerivedFromTool(
- record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
+ record({ toolHistory: [{ toolId: "compress", timestamp: 0 }] }),
),
).toBe(true);
});
diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts
index 27b8746604..b0cede48cb 100644
--- a/frontend/editor/src/core/services/fileStorage.ts
+++ b/frontend/editor/src/core/services/fileStorage.ts
@@ -10,6 +10,7 @@ import {
StirlingFile,
StirlingFileStub,
createStirlingFile,
+ type ClassificationConfidence,
} from "@app/types/fileContext";
import {
indexedDBManager,
@@ -36,6 +37,8 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
// group by label without re-reading PDF bytes, and it survives versioning.
// See StirlingFileStub.classificationLabels.
classificationLabels?: string[];
+ // See StirlingFileStub.classificationConfidence.
+ classificationConfidence?: ClassificationConfidence;
}
export interface StorageStats {
@@ -695,6 +698,7 @@ class FileStorageService {
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
classificationLabels: record.classificationLabels,
+ classificationConfidence: record.classificationConfidence,
};
resolve(stub);
@@ -762,6 +766,7 @@ class FileStorageService {
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
classificationLabels: record.classificationLabels,
+ classificationConfidence: record.classificationConfidence,
});
}
cursor.continue();
@@ -861,6 +866,7 @@ class FileStorageService {
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
classificationLabels: record.classificationLabels,
+ classificationConfidence: record.classificationConfidence,
});
}
cursor.continue();
diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts
index 5e948f8ba7..a218a43428 100644
--- a/frontend/editor/src/core/services/fileSyncService.ts
+++ b/frontend/editor/src/core/services/fileSyncService.ts
@@ -131,7 +131,7 @@ export async function reconcileServerFiles(
{
suppressErrorToast: true,
skipAuthRedirect: true,
- } as any,
+ },
);
const serverFiles = Array.isArray(response.data) ? response.data : [];
const serverMap = new Map();
@@ -280,7 +280,7 @@ export async function reconcileServerFiles(
try {
const response = await apiClient.get(
"/api/v1/storage/share-links/accessed",
- { suppressErrorToast: true, skipAuthRedirect: true } as any,
+ { suppressErrorToast: true, skipAuthRedirect: true },
);
const sharedLinks = Array.isArray(response.data) ? response.data : [];
const allowed = new Set(
@@ -426,7 +426,7 @@ export async function materializeServerStubs(
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
- } as any);
+ });
const rawHeaders = (response.headers ?? {}) as Record & {
get?: (name: string) => string | null;
};
diff --git a/frontend/editor/src/core/services/googleDrivePickerService.ts b/frontend/editor/src/core/services/googleDrivePickerService.ts
index 9d91ffdeac..f54e900b01 100644
--- a/frontend/editor/src/core/services/googleDrivePickerService.ts
+++ b/frontend/editor/src/core/services/googleDrivePickerService.ts
@@ -5,6 +5,13 @@
import { loadScript } from "@app/utils/scriptLoader";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
+import { AppConfig } from "@app/types/appConfig";
+
+// The GIS token client lets you reassign `callback` after init (to resolve the
+// per-request promise), but @types/google.accounts only models it on the config.
+type TokenClientWithCallback = google.accounts.oauth2.TokenClient & {
+ callback: (response: google.accounts.oauth2.TokenResponse) => void;
+};
const SCOPES = "https://www.googleapis.com/auth/drive.readonly";
const SESSION_STORAGE_ID = "googleDrivePickerAccessToken";
@@ -59,7 +66,7 @@ function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
class GoogleDrivePickerService {
private config: GoogleDriveConfig | null = null;
- private tokenClient: any = null;
+ private tokenClient: TokenClientWithCallback | null = null;
private accessToken: string | null = null;
private gapiLoaded = false;
private gisLoaded = false;
@@ -119,7 +126,7 @@ class GoogleDrivePickerService {
client_id: this.config.clientId,
scope: SCOPES,
callback: () => {}, // Will be overridden during picker creation
- });
+ }) as TokenClientWithCallback;
this.gisLoaded = true;
}
@@ -149,7 +156,9 @@ class GoogleDrivePickerService {
return;
}
- this.tokenClient.callback = (response: any) => {
+ this.tokenClient.callback = (
+ response: google.accounts.oauth2.TokenResponse,
+ ) => {
if (response.error !== undefined) {
reject(new Error(response.error));
return;
@@ -201,7 +210,9 @@ class GoogleDrivePickerService {
.setOAuthToken(this.accessToken)
.addView(view1)
.addView(view2)
- .setCallback((data: any) => this.pickerCallback(data, resolve, reject));
+ .setCallback((data: google.picker.ResponseObject) =>
+ this.pickerCallback(data, resolve, reject),
+ );
(builder as unknown as { setZIndex(z: number): void }).setZIndex(
Z_INDEX_OVER_FILE_MANAGER_MODAL,
@@ -220,37 +231,39 @@ class GoogleDrivePickerService {
* Handle picker selection callback
*/
private async pickerCallback(
- data: any,
+ data: google.picker.ResponseObject,
resolve: (files: File[]) => void,
reject: (error: Error) => void,
): Promise {
- if (data.action === window.google.picker.Action.PICKED) {
+ const action = data[window.google.picker.Response.ACTION];
+ if (action === window.google.picker.Action.PICKED) {
try {
+ const documents = data[window.google.picker.Response.DOCUMENTS] ?? [];
const files = await Promise.all(
- data[window.google.picker.Response.DOCUMENTS].map(
- async (pickedFile: any) => {
- const fileId = pickedFile[window.google.picker.Document.ID];
- const res = await window.gapi.client.drive.files.get({
- fileId: fileId,
- alt: "media",
- });
+ documents.map(async (pickedFile) => {
+ const fileId = pickedFile[window.google.picker.Document.ID];
+ const res = await window.gapi.client.drive.files.get({
+ fileId: fileId,
+ alt: "media",
+ });
- // Convert response body to File object
- const file = new File(
- [
- new Uint8Array(res.body.length).map((_: any, i: number) =>
- res.body.charCodeAt(i),
- ),
- ],
- pickedFile.name,
- {
- type: pickedFile.mimeType,
- lastModified: pickedFile.lastModified,
- },
- );
- return file;
- },
- ),
+ // Convert response body to File object
+ const file = new File(
+ [
+ new Uint8Array(res.body.length).map((_, i) =>
+ res.body.charCodeAt(i),
+ ),
+ ],
+ pickedFile[window.google.picker.Document.NAME] ?? "",
+ {
+ type: pickedFile[window.google.picker.Document.MIME_TYPE],
+ lastModified:
+ pickedFile[window.google.picker.Document.LAST_EDITED_UTC] ??
+ Date.now(),
+ },
+ );
+ return file;
+ }),
);
resolve(files);
@@ -261,7 +274,7 @@ class GoogleDrivePickerService {
: new Error("Failed to download files"),
);
}
- } else if (data.action === window.google.picker.Action.CANCEL) {
+ } else if (action === window.google.picker.Action.CANCEL) {
resolve([]); // User cancelled, return empty array
}
}
@@ -369,7 +382,7 @@ export function getGoogleDriveConfig(
* Eliminates duplicated config construction pattern
*/
export function extractGoogleDriveBackendConfig(
- appConfig: any,
+ appConfig: AppConfig | null,
): BackendGoogleDriveConfig {
return {
enabled: appConfig?.googleDriveEnabled,
diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts
index 55b0a425f9..dd0c692d97 100644
--- a/frontend/editor/src/core/services/httpErrorHandler.ts
+++ b/frontend/editor/src/core/services/httpErrorHandler.ts
@@ -6,6 +6,7 @@ import {
normalizeAxiosErrorData,
} from "@app/services/errorUtils";
import { showSpecialErrorToast } from "@app/services/specialErrorToasts";
+import axios from "axios";
import { handleSaaSError } from "@app/services/saasErrorInterceptor";
import {
clampText,
@@ -95,15 +96,16 @@ if (typeof window !== "undefined") {
* Handles HTTP errors with toast notifications and file error broadcasting
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
-export async function handleHttpError(error: any): Promise {
- const skipAuthRedirect = error?.config?.skipAuthRedirect === true;
+export async function handleHttpError(error: unknown): Promise {
+ const axiosError = axios.isAxiosError(error) ? error : undefined;
+ const skipAuthRedirect = axiosError?.config?.skipAuthRedirect === true;
// Check if this error should skip the global toast (component will handle it)
- if (error?.config?.suppressErrorToast === true) {
+ if (axiosError?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Handle 401 authentication errors
- const status: number | undefined = error?.response?.status;
+ const status: number | undefined = axiosError?.response?.status;
if (status === 401) {
const pathname = window.location.pathname;
@@ -119,7 +121,7 @@ export async function handleHttpError(error: any): Promise {
if (loginRedirectRecentlyFired()) {
console.warn(
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
- error?.config?.url,
+ axiosError?.config?.url,
);
return true;
}
@@ -151,7 +153,7 @@ export async function handleHttpError(error: any): Promise {
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
- const raw = error?.response?.data as any;
+ const raw = axiosError?.response?.data;
let normalized: unknown = raw;
try {
normalized = await normalizeAxiosErrorData(raw);
@@ -170,7 +172,7 @@ export async function handleHttpError(error: any): Promise {
}
// 2) Generic-vs-special dedupe by endpoint
- const url: string | undefined = error?.config?.url;
+ const url: string | undefined = axiosError?.config?.url;
const now = Date.now();
const isSpecial =
status === 422 ||
diff --git a/frontend/editor/src/core/services/httpErrorUtils.ts b/frontend/editor/src/core/services/httpErrorUtils.ts
index b3b54e2664..c4898266c3 100644
--- a/frontend/editor/src/core/services/httpErrorUtils.ts
+++ b/frontend/editor/src/core/services/httpErrorUtils.ts
@@ -25,14 +25,14 @@ function titleForStatus(status?: number): string {
return "Request failed";
}
-export function extractAxiosErrorMessage(error: any): {
+export function extractAxiosErrorMessage(error: unknown): {
title: string;
body: string;
} {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || "";
- let parsed: any = undefined;
+ let parsed: unknown = undefined;
const raw = error.response?.data;
if (typeof raw === "string") {
try {
@@ -44,8 +44,8 @@ export function extractAxiosErrorMessage(error: any): {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
- if (Array.isArray(parsed?.errorFileIds))
- return parsed.errorFileIds as string[];
+ const errorFileIds = (parsed as { errorFileIds?: unknown })?.errorFileIds;
+ if (Array.isArray(errorFileIds)) return errorFileIds as string[];
const rawText = typeof raw === "string" ? raw : "";
const uuidMatches = rawText.match(
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
@@ -60,7 +60,8 @@ export function extractAxiosErrorMessage(error: any): {
if (!data) return typeof raw === "string" ? raw : "";
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(", ")}`;
- if (data?.message) return data.message as string;
+ const message = (data as { message?: unknown })?.message;
+ if (message) return message as string;
if (typeof raw === "string") return raw;
try {
return JSON.stringify(data);
@@ -82,7 +83,8 @@ export function extractAxiosErrorMessage(error: any): {
return { title, body: bodyMsg };
}
try {
- const msg = (error?.message || String(error)) as string;
+ const msg = ((error as { message?: unknown })?.message ||
+ String(error)) as string;
return {
title: "Network error",
body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg,
diff --git a/frontend/editor/src/core/services/pdfWorkerManager.ts b/frontend/editor/src/core/services/pdfWorkerManager.ts
index 534c6fc3f1..6da74e1dd1 100644
--- a/frontend/editor/src/core/services/pdfWorkerManager.ts
+++ b/frontend/editor/src/core/services/pdfWorkerManager.ts
@@ -38,7 +38,7 @@ class PDFWorkerManager {
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
- (GlobalWorkerOptions as any).docBaseUrl = undefined;
+ (GlobalWorkerOptions as { docBaseUrl?: string }).docBaseUrl = undefined;
this.isInitialized = true;
}
}
@@ -62,7 +62,7 @@ class PDFWorkerManager {
}
// Normalize input data to PDF.js format
- let pdfData: any;
+ let pdfData: string | { data: ArrayBuffer | Uint8Array };
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
pdfData = { data };
} else if (typeof data === "string") {
diff --git a/frontend/editor/src/core/services/signatureDetectionService.ts b/frontend/editor/src/core/services/signatureDetectionService.ts
index 0062380a79..41bd9e267c 100644
--- a/frontend/editor/src/core/services/signatureDetectionService.ts
+++ b/frontend/editor/src/core/services/signatureDetectionService.ts
@@ -4,13 +4,6 @@
* without needing to make API calls
*/
-// PDF.js types (simplified)
-declare global {
- interface Window {
- pdfjsLib?: any;
- }
-}
-
export interface SignatureDetectionResult {
hasSignatures: boolean;
signatureCount?: number;
@@ -53,7 +46,7 @@ const detectSignaturesInFile = async (
// Count signature annotations (Type: /Sig)
const signatureAnnotations = annotations.filter(
- (annotation: any) =>
+ (annotation: { subtype?: string; fieldType?: string }) =>
annotation.subtype === "Widget" && annotation.fieldType === "Sig",
);
@@ -62,7 +55,11 @@ const detectSignaturesInFile = async (
// Also check for document-level signatures in AcroForm
const metadata = await pdf.getMetadata();
- if (metadata?.info?.Signature || metadata?.metadata?.has("dc:signature")) {
+ const info = metadata?.info as { Signature?: unknown } | undefined;
+ const xmpMetadata = metadata?.metadata as
+ | { has?: (name: string) => boolean }
+ | undefined;
+ if (info?.Signature || xmpMetadata?.has?.("dc:signature")) {
totalSignatures = Math.max(totalSignatures, 1);
}
diff --git a/frontend/editor/src/core/services/signatureStorageService.ts b/frontend/editor/src/core/services/signatureStorageService.ts
index 1cc0ac9360..da9bb9332d 100644
--- a/frontend/editor/src/core/services/signatureStorageService.ts
+++ b/frontend/editor/src/core/services/signatureStorageService.ts
@@ -1,3 +1,4 @@
+import axios from "axios";
import apiClient from "@app/services/apiClient";
import type { SavedSignature } from "@app/types/signature";
import { readResponseHeader } from "@app/services/shareBundleUtils";
@@ -54,14 +55,17 @@ class SignatureStorageService {
supportsBackend: true,
storageType: "backend",
};
- } catch (error: any) {
+ } catch (error: unknown) {
+ const status = axios.isAxiosError(error)
+ ? error.response?.status
+ : undefined;
// Check if it's an HTTP error with status code
- if (error?.response?.status === 401 || error?.response?.status === 403) {
+ if (status === 401 || status === 403) {
// Backend exists but needs auth - gracefully fall back to localStorage
console.log(
"[SignatureStorage] Backend signature API requires authentication, using localStorage",
);
- } else if (error?.response?.status === 404) {
+ } else if (status === 404) {
// Endpoint doesn't exist (not running proprietary mode)
console.log(
"[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage",
diff --git a/frontend/editor/src/core/services/specialErrorToasts.ts b/frontend/editor/src/core/services/specialErrorToasts.ts
index 6a38a54f53..f2ee343803 100644
--- a/frontend/editor/src/core/services/specialErrorToasts.ts
+++ b/frontend/editor/src/core/services/specialErrorToasts.ts
@@ -1,3 +1,4 @@
+import i18n from "i18next";
import { alert } from "@app/components/toast";
interface ErrorToastMapping {
@@ -42,18 +43,13 @@ export function showSpecialErrorToast(
for (const mapping of MAPPINGS) {
if (mapping.regex.test(message)) {
- // Best-effort translation without hard dependency on i18n config
let body = mapping.defaultMessage;
- try {
- const anyGlobal: any = globalThis as any;
- const i18next = anyGlobal?.i18next;
- if (i18next && typeof i18next.t === "function") {
- body = i18next.t(mapping.i18nKey, {
- defaultValue: mapping.defaultMessage,
- });
- }
- } catch {
- /* ignore translation errors */
+ // The app bootstraps this shared i18next singleton at startup; guard in
+ // case a toast fires before that (e.g. tests) so we keep the default copy.
+ if (i18n.isInitialized) {
+ body = i18n.t(mapping.i18nKey, {
+ defaultValue: mapping.defaultMessage,
+ });
}
const title = titleForStatus(options?.status);
alert({
diff --git a/frontend/editor/src/core/services/usageAnalyticsService.ts b/frontend/editor/src/core/services/usageAnalyticsService.ts
index 8f580c820b..f7ba3dd79e 100644
--- a/frontend/editor/src/core/services/usageAnalyticsService.ts
+++ b/frontend/editor/src/core/services/usageAnalyticsService.ts
@@ -25,7 +25,7 @@ const usageAnalyticsService = {
limit?: number,
dataType: "all" | "api" | "ui" = "all",
): Promise {
- const params: Record = {};
+ const params: Record = {};
if (limit !== undefined) {
params.limit = limit;
diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts
index 4676137abf..b4a48e3d00 100644
--- a/frontend/editor/src/core/types/fileContext.ts
+++ b/frontend/editor/src/core/types/fileContext.ts
@@ -9,6 +9,9 @@ import { generateId } from "@app/utils/generateId";
// Re-export FileId for convenience
export type { FileId };
+/** How sure a classifier was about the labels it produced. */
+export type ClassificationConfidence = "none" | "low" | "medium" | "high";
+
// Normalized state types
export interface ProcessedFilePage {
thumbnail?: string;
@@ -61,6 +64,11 @@ export interface StirlingFileStub extends BaseFileMetadata {
* unclassified files / non-SaaS builds.
*/
classificationLabels?: string[];
+ /**
+ * How sure the local heuristic was about {@link classificationLabels}: a confident verdict
+ * stands, an unsure one escalates to the AI. Undefined when the labels came from the AI.
+ */
+ classificationConfidence?: ClassificationConfidence;
/**
* This session proved the stored bytes unreadable (WebKit losing a blob's
* backing store). The row renders as "data lost" instead of pretending the
diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts
index cea05ca20a..55938d231e 100644
--- a/frontend/editor/src/core/types/toolApiTypes.ts
+++ b/frontend/editor/src/core/types/toolApiTypes.ts
@@ -207,6 +207,9 @@ export interface AddWatermarkRequest {
*/
widthSpacer?: number;
}
+export interface AiToolsClassifyAndLabelRequest {
+ reclassify?: boolean;
+}
export interface AutoRotatePdfRequest {
/**
* Minimum Tesseract OSD orientation confidence required before a correction is applied. Matches OCRmyPDF's --rotate-pages-threshold scale
@@ -1485,6 +1488,7 @@ export interface UrlToPdfRequest {
/** Endpoint path for a generated tool operation (the operation identity across languages). */
export type ToolEndpoint =
+ | "/api/v1/ai/tools/classify-and-label"
| "/api/v1/convert/cbr/pdf"
| "/api/v1/convert/cbz/pdf"
| "/api/v1/convert/ebook/pdf"
@@ -1587,6 +1591,7 @@ export type ToolEndpoint =
/** Backend request-parameter model for each tool endpoint. */
export interface ToolApiParams {
+ "/api/v1/ai/tools/classify-and-label": AiToolsClassifyAndLabelRequest;
"/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest;
"/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest;
"/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest;
@@ -1690,6 +1695,7 @@ export interface ToolApiParams {
/** Every generated tool endpoint, for iteration. */
export const TOOL_ENDPOINTS = [
+ "/api/v1/ai/tools/classify-and-label",
"/api/v1/convert/cbr/pdf",
"/api/v1/convert/cbz/pdf",
"/api/v1/convert/ebook/pdf",
diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts
index 4a03a52102..9d672b2d6d 100644
--- a/frontend/editor/src/core/types/toolIO.ts
+++ b/frontend/editor/src/core/types/toolIO.ts
@@ -99,6 +99,11 @@ export interface ToolIOSpec {
export type ToolIOTable = Partial>;
export const TOOL_IO: ToolIOTable = {
+ "/api/v1/ai/tools/classify-and-label": {
+ accepts: ["PDF"],
+ produces: "PDF",
+ arity: "SISO",
+ },
"/api/v1/convert/cbr/pdf": {
accepts: ["CBR"],
produces: "PDF",
diff --git a/frontend/editor/src/core/types/toolId.ts b/frontend/editor/src/core/types/toolId.ts
index 13799ad869..0762c2e81d 100644
--- a/frontend/editor/src/core/types/toolId.ts
+++ b/frontend/editor/src/core/types/toolId.ts
@@ -50,6 +50,7 @@ export const CORE_REGULAR_TOOL_IDS = [
"removeCertSign",
"unlockPDFForms",
"compress",
+ "classify",
"extractPages",
"reorganizePages",
"extractImages",
diff --git a/frontend/editor/src/core/utils/toolSearch.ts b/frontend/editor/src/core/utils/toolSearch.ts
index 41b40135a5..1a543974a3 100644
--- a/frontend/editor/src/core/utils/toolSearch.ts
+++ b/frontend/editor/src/core/utils/toolSearch.ts
@@ -15,7 +15,11 @@ export function filterToolRegistryByQuery(
toolRegistry: Partial,
query: string,
): RankedToolItem[] {
- const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
+ // The single funnel into the editor's tool list, so hiding here hides it everywhere the user
+ // browses - while getExecutableTools still offers it to a pipeline.
+ const entries = (
+ Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]
+ ).filter(([, tool]) => !tool?.hiddenFromToolList);
if (!query.trim()) {
return entries.map(([id, tool]) => ({
item: [id, tool] as [ToolId, ToolRegistryEntry],
diff --git a/frontend/editor/src/global.d.ts b/frontend/editor/src/global.d.ts
index cb6275c471..6b38e511b4 100644
--- a/frontend/editor/src/global.d.ts
+++ b/frontend/editor/src/global.d.ts
@@ -22,6 +22,7 @@ declare global {
__STIRLING_PDF_BASE_URL__?: string;
STIRLING_PDF_API_BASE_URL?: string;
endpointAvailabilityService?: unknown;
+ pdfjsLib?: typeof import("pdfjs-dist");
}
}
diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
index 2925958efd..75da086806 100644
--- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
+++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx
@@ -215,22 +215,19 @@ const BIG_MEMBERS: Member[] = [
isSelf: true,
portalAccess: "admin",
},
- ...Array.from(
- { length: 11 },
- (_, i): Member => ({
- id: `big-${i}`,
- name: `Teammate ${i + 1}`,
- email: `teammate${i + 1}@acme.com`,
- role: i === 0 ? "team_owner" : "member",
- status: "active",
- lastActive: `${i + 1}h ago`,
- username: `tm${i + 1}`,
- teamId: 9,
- teamName: "Platform",
- teamLead: i === 0,
- portalAccess: i === 0 ? "role" : "none",
- }),
- ),
+ ...Array.from({ length: 11 }, (_, i): Member => ({
+ id: `big-${i}`,
+ name: `Teammate ${i + 1}`,
+ email: `teammate${i + 1}@acme.com`,
+ role: i === 0 ? "team_owner" : "member",
+ status: "active",
+ lastActive: `${i + 1}h ago`,
+ username: `tm${i + 1}`,
+ teamId: 9,
+ teamName: "Platform",
+ teamLead: i === 0,
+ portalAccess: i === 0 ? "role" : "none",
+ })),
];
const TEAMS: Team[] = [
diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts
index aac4b7e3da..64fd849d87 100644
--- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts
+++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts
@@ -1,5 +1,5 @@
-// With the AI engine off, the Classification policy runs here in the browser:
-// each upload is labelled by the heuristic engine and metered for billing parity.
+// The Classification policy's first pass: every upload is labelled locally before the AI is asked.
+// The confidence reported here decides whether the AI is asked at all - see usePolicyAutoRun.
import { useEffect, useRef, useState } from "react";
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
@@ -7,7 +7,6 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { fileStorage } from "@app/services/fileStorage";
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
-import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
import { scheduleIdle } from "@app/utils/scheduleIdle";
import { usePolicies } from "@app/hooks/usePolicies";
import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification";
@@ -15,12 +14,14 @@ import { meterClassificationRun } from "@app/services/classificationMeter";
import {
isDispatched,
markDispatched,
+ recordRunStart,
+ updateRun,
} from "@app/components/policies/policyRunStore";
import type { FileId } from "@app/types/file";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
+import type { HeuristicConfidence } from "@app/services/heuristic/types";
+import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy";
-/** The category id of the Classification policy (see policyDefinitions). */
-const CLASSIFICATION_CATEGORY = "classification";
/** Files classified per idle pass, so a large library drains over several ticks. */
const CLASSIFY_BATCH = 3;
/** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s).
@@ -47,9 +48,8 @@ export function useClientSideClassification(): void {
const { bumpRevision } = useIndexedDB();
const { policies } = usePolicies();
const classificationEnabled = useClassificationEnabled();
- const aiEnabled = useAiEngineEnabled();
- // While app-config loads, aiEnabled reads false even on AI-on tenants; classifying
- // in that window would double-run (and double-bill) files the server also labels.
+ // Still waited on: a verdict written before app-config lands would be acted on by the
+ // escalation decision before it knows whether the AI engine is even available.
const { loading: configLoading } = useAppConfig();
// Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is
// taken synchronously right before classifying, so overlapping batches never double-classify.
@@ -57,7 +57,9 @@ export function useClientSideClassification(): void {
// Bumped after each batch to drain the next one.
const [tick, setTick] = useState(0);
- const policy = policies[CLASSIFICATION_CATEGORY];
+ // TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify
+ // step gets no local pass - suppressing one step of a chain is not expressible today.
+ const policy = policies[CLASSIFICATION_CATEGORY_ID];
// Only when the admin has an active Classification policy - the same gate the AI path uses.
const active = Boolean(
policy?.configured &&
@@ -69,7 +71,8 @@ export function useClientSideClassification(): void {
);
useEffect(() => {
- if (configLoading || !classificationEnabled || aiEnabled || !active) {
+ // Runs whether or not the AI engine is on: it is the first pass either way, not a fallback.
+ if (configLoading || !classificationEnabled || !active) {
return;
}
const claimKey = (s: StirlingFileStub) =>
@@ -95,17 +98,23 @@ export function useClientSideClassification(): void {
// Re-validate at execution time - another batch may have claimed it since.
if (claimed.current.has(key)) continue;
claimed.current.add(key);
- const labels = await classifyStub(stub.id as FileId, stub.name);
+ const verdict = await classifyStub(
+ stub.id as FileId,
+ stub.name,
+ stub.size ?? 0,
+ );
// Bytes never landed (file removed mid-wait): leave undelivered so a
// reload (or new version) retries; the claim stops churn this session.
- if (labels == null) continue;
+ if (verdict == null) continue;
// Deliver unconditionally - a re-render must never discard a computed
// (and already metered) result. Writes are idempotent.
updateStirlingFileStub(stub.id as FileId, {
- classificationLabels: labels,
+ classificationLabels: verdict.labels,
+ classificationConfidence: verdict.confidence,
});
const ok = await fileStorage.updateFileMetadata(stub.id as FileId, {
- classificationLabels: labels,
+ classificationLabels: verdict.labels,
+ classificationConfidence: verdict.confidence,
});
if (ok) wrote = true;
}
@@ -122,7 +131,6 @@ export function useClientSideClassification(): void {
fileStubs,
active,
classificationEnabled,
- aiEnabled,
configLoading,
updateStirlingFileStub,
bumpRevision,
@@ -134,7 +142,8 @@ export function useClientSideClassification(): void {
async function classifyStub(
fileId: FileId,
fileName: string,
-): Promise {
+ fileSize: number,
+): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> {
let file: StirlingFile | null = null;
for (let i = 0; i < FILE_WAIT_TRIES; i++) {
file = await fileStorage.getStirlingFile(fileId).catch(() => null);
@@ -149,10 +158,28 @@ async function classifyStub(
}
const debug = isClassificationDebug();
const startedAt = performance.now();
+ // A local run is still a billable policy run, so it belongs in the activity feed; recorded only
+ // once the bytes are in hand, so a file whose bytes never land leaves no phantom row.
+
+ // Read before recordRunStart, which takes the dispatch key itself and would otherwise always
+ // answer "already dispatched", silently stopping metering.
+ const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
+ const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`;
+ recordRunStart({
+ runId,
+ categoryId: CLASSIFICATION_CATEGORY_ID,
+ fileId: fileId as string,
+ fileName,
+ fileSize,
+ target: "local",
+ status: "RUNNING",
+ outputs: [],
+ error: null,
+ startedAt: Date.now(),
+ });
try {
const result = await classifyFileHeuristically(file, { explain: debug });
const { labels } = result;
- const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY, fileId);
const ms = Math.round(performance.now() - startedAt);
const verdict =
labels.length > 0
@@ -174,12 +201,22 @@ async function classifyStub(
labels,
});
}
- markDispatched(CLASSIFICATION_CATEGORY, fileId);
- return labels;
+ markDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
+ // Labels, no output file - the same settle shape the server-run classification uses.
+ updateRun(runId, {
+ status: "COMPLETED",
+ imported: true,
+ outputFileIds: [fileId as string],
+ });
+ return { labels, confidence: result.confidence };
} catch (err) {
// Never persist a verdict for an unreadable file - the failure may be
// environmental, so it must stay eligible to retry (and meter) later.
console.warn(`[Classify] ${fileName}: could not be read, will retry`, err);
+ updateRun(runId, {
+ status: "FAILED",
+ error: err instanceof Error ? err.message : String(err),
+ });
return null;
}
}
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
index bc4fa54dff..08768f1aa7 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
+import type { ClassificationConfidence } from "@app/types/fileContext";
/**
* Batch integration test (61 files, two chained upload policies) driving the real
@@ -12,7 +13,11 @@ const FILE_COUNT = 61;
// the workbench, mirrored into useAllFiles. consumeFiles mutates it in place
// (input id → output id) exactly as the real silent reducer would.
const mocks = vi.hoisted(() => ({
- workspace: [] as Array<{ id: string; classificationLabels?: string[] }>,
+ workspace: [] as Array<{
+ id: string;
+ classificationLabels?: string[];
+ classificationConfidence?: ClassificationConfidence;
+ }>,
consumeSilentCalls: 0,
consumeNonSilentCalls: 0,
persistCalls: 0,
@@ -117,10 +122,20 @@ function Harness() {
return null;
}
+/** The heuristic verdict that escalates to the AI classifier; only "high" stands alone. */
+const LOW = "low" as const;
+
function replaceInWorkspace(inputIds: string[], outputIds: string[]) {
+ // A versioned output carries its input's heuristic verdict; the escalation decision is about the
+ // document, not about which step produced the current bytes.
+ const inherited =
+ mocks.workspace.find((s) => inputIds.includes(s.id))
+ ?.classificationConfidence ?? LOW;
mocks.workspace = mocks.workspace
.filter((s) => !inputIds.includes(s.id))
- .concat(outputIds.map((id) => ({ id })));
+ .concat(
+ outputIds.map((id) => ({ id, classificationConfidence: inherited })),
+ );
}
beforeEach(() => {
@@ -138,6 +153,7 @@ beforeEach(() => {
mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({
id: `file-${i}`,
+ classificationConfidence: LOW,
}));
mocks.listPolicyRuns.mockResolvedValue([]);
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx
index b67648e7fc..84b6f58bf7 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
+import type { ClassificationConfidence } from "@app/types/fileContext";
/**
* Mid-run race: classification is in flight (its labelled output is still
@@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({
sourceFileIds?: string[];
derivedFromTool?: boolean;
classificationLabels?: string[];
+ classificationConfidence?: ClassificationConfidence;
}>,
runStoredPolicy: vi.fn(),
getPolicyRun: vi.fn(),
@@ -114,7 +116,7 @@ beforeEach(() => {
resetPolicyRuns();
vi.clearAllMocks();
- mocks.workspace = [{ id: "file-0" }];
+ mocks.workspace = [{ id: "file-0", classificationConfidence: "low" }];
mocks.listPolicyRuns.mockResolvedValue([]);
mocks.getStirlingFile.mockResolvedValue(
diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
index aeda0f0c25..936fef8119 100644
--- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
+++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts
@@ -1,18 +1,6 @@
/**
- * Auto-run controller: every enabled policy enforces on every uploaded file.
- * Watches the session's files and fires a real backend run
- * (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and
- * recording progress in {@link policyRunStore} for the activity feed.
- *
- * When several policies enforce on the same trigger they run as an ordered chain:
- * the first fires on the upload, and each subsequent policy fires on the previous
- * one's output once it lands — so their effects accumulate in the admin-defined
- * order rather than racing to fork the same version.
- *
- * Headless — call it from {@link PolicyAutoRunController}, which is mounted once
- * wherever the editor is open so enforcement happens regardless of whether the
- * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked
- * in the run store), so re-renders and remounts don't re-fire.
+ * Headless auto-run controller: one backend run per (policy, file), fired exactly once and polled.
+ * Policies sharing a trigger run as an ordered chain so their effects accumulate.
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
@@ -40,7 +28,12 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import type { FileId } from "@app/types/file";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
import { readClassificationLabelsFromFile } from "@app/services/fileClassification";
-import { isClassificationCategory } from "@app/data/policyCategories";
+import {
+ policyDeliversOutputFiles,
+ policyRequiresAiEngine,
+ policyRewritesDocument,
+ shouldDispatchToAi,
+} from "@app/data/classificationPolicy";
import {
acquireDispatchSlot,
releaseDispatchSlot,
@@ -69,10 +62,8 @@ const POLL_MS = 2000;
* sitting on an indeterminate spinner for a full poll interval. */
const FIRST_POLL_MS = 500;
-/** The server aborts any single tool step that runs longer than its internal-API
- * read timeout, then fails the run — so a run can legitimately stay in flight
- * for up to this long per step. The client must keep polling at least that long,
- * or it abandons a run the server is still working on (which reads as a hang). */
+/** Server's per-step abort budget - poll at least this long per step, or we abandon
+ * a run the server is still working on. */
const STEP_TIMEOUT_MS = 300_000;
/** Slack on top of the per-step budget: queueing before the first step starts and
@@ -92,11 +83,8 @@ const POLICY_QUEUE_FULL = "POLICY_QUEUE_FULL";
const MAX_QUEUE_RETRIES = 5;
const QUEUE_RETRY_BASE_MS = 4000;
-/** Consecutive "run not found" responses before giving up. The run state lives
- * in memory on the server, so a restart or a second instance behind the load
- * balancer makes a live run's status return 404 — and it won't come back. We
- * tolerate a brief blip (e.g. a poll racing a just-dispatched run, or one hop
- * to an instance that hasn't seen it) then fail, rather than polling forever. */
+/** Consecutive 404s before failing. Run state is in-memory server-side, so a restart
+ * or a hop to another instance loses it permanently - tolerate a blip, not forever. */
const MAX_NOT_FOUND = 3;
/** A 404 (run status gone, or output file gone), across the web (axios) and
@@ -119,24 +107,20 @@ function failRun(runId: string, message: string): void {
updateRun(runId, { status: "FAILED", error: message, errorCode: null });
}
-/** How long to wait for an upload's bytes to land in IndexedDB before giving up
- * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its
- * bytes are committed, so a too-eager fetch would otherwise miss the file. */
+/** Wait for an upload's bytes to land in IndexedDB (~5s): the stub surfaces in the
+ * file list before its bytes are committed, so an eager fetch would miss the file. */
const FILE_WAIT_TRIES = 20;
const FILE_WAIT_MS = 250;
-/**
- * A policy that changed nothing (redaction matched no text, say) completes with no
- * output: nothing to deliver, but finished. Left unimported, the file's badge and
- * its blocking overlay spin forever.
- */
+/** A policy that changed nothing completes with no output; left unimported its badge
+ * and blocking overlay spin forever. */
export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean {
return (
run.status === "COMPLETED" &&
!run.imported &&
(run.outputs?.length ?? 0) === 0 &&
- // Classification has its own settle path: labels, no output file.
- !isClassificationCategory(run.categoryId)
+ // An annotating policy settles on labels, not an output file.
+ policyDeliversOutputFiles(run.categoryId)
);
}
@@ -156,34 +140,23 @@ export function usePolicyAutoRun(): void {
const { policies } = usePolicies();
const aiEnabled = useAiEngineEnabled();
const runs = usePolicyRuns();
- // Live view of the workspace files, read inside the import effect WITHOUT making
- // it a dependency. The silent consume that delivers an output mutates fileStubs,
- // so if the import effect depended on fileStubs it would re-fire on its own
- // delivery — an infinite import cascade (and a bumpRevision storm that trips
- // React's max-update-depth). The effect only needs to fire when `runs` changes.
+ // Read in the import effect via ref, not as a dependency: delivery mutates fileStubs,
+ // so depending on them would re-fire the effect on its own delivery (infinite cascade).
const fileStubsRef = useRef(fileStubs);
fileStubsRef.current = fileStubs;
- // Keys (run ids / dispatch keys) currently in flight, so the effects never
- // double-fire across re-renders while their first async step is pending.
+ // Keys in flight, so effects never double-fire across re-renders while async work pends.
const polling = useRef>(new Set());
const importing = useRef>(new Set());
const dispatching = useRef>(new Set());
// Reconcile against the backend exactly once per mount.
const reconciled = useRef(false);
- // A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient
- // interceptor (and thus never pops the modal that direct calls get). The backend surfaces the
- // limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a
- // window event. A saas-layer listener (which can read the wallet + open the modal — this
- // proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per
- // run so a folder-watch burst opens the modal once, not once per file.
+ // Server-side runs never hit the apiClient 402 interceptor, so we broadcast the limit
+ // sentinel for a saas listener to open the modal. Deduped per run.
const firedLimitModal = useRef>(new Set());
- // Active upload policies in execution order. When several enforce on upload they
- // run as a chain — the first fires on the upload, each subsequent one on the
- // previous policy's output — so their effects accumulate in a defined order
- // instead of racing to fork the same version. Mirrors the dispatch filter
- // (incl. the editor-source gate) so the chain honours the same eligibility.
+ // Active upload policies in chain order, so effects accumulate instead of racing to fork
+ // the same version. Mirrors the dispatch filter so the chain honours the same eligibility.
const orderedUploadCategories = useMemo(
() =>
Object.entries(policies)
@@ -196,41 +169,40 @@ export function usePolicyAutoRun(): void {
s.sources.length === 0 ||
s.sources.includes("editor")) &&
(s.runOn ?? "upload") === "upload" &&
- // Non-AI systems classify in the browser (useClientSideClassification), so keep the
- // Classification policy out of the server chain when the AI engine is off.
- !(id === "classification" && !aiEnabled),
+ // An escalation-only policy has nothing to do with no engine to escalate to.
+ !(policyRequiresAiEngine(id) && !aiEnabled),
)
- // Classification runs last: it's non-blocking, so an enforcement policy
- // running after it would fork a new version and drop the user's edits.
+ // Annotating policies run last: a rewriting one after them would fork a new
+ // version from the pre-annotation state and drop their labels.
.sort(([idA, a], [idB, b]) => {
- const ca = isClassificationCategory(idA) ? 1 : 0;
- const cb = isClassificationCategory(idB) ? 1 : 0;
- if (ca !== cb) return ca - cb;
+ const ra = policyRewritesDocument(idA) ? 0 : 1;
+ const rb = policyRewritesDocument(idB) ? 0 : 1;
+ if (ra !== rb) return ra - rb;
return (a.order ?? 0) - (b.order ?? 0);
})
.map(([id]) => id),
[policies, aiEnabled],
);
- // Runs whose chain-continuation we've already handled this session, so the next
- // policy is dispatched exactly once per completed run.
+ // Chain-continuations handled this session, so the next policy fires once per run.
const chained = useRef>(new Set());
// Latest policies, read from inside the stable retry callback (which has no deps).
const policiesRef = useRef(policies);
policiesRef.current = policies;
+ // Latest stubs for the chaining effect, which keys off runs and must not depend on stubs.
+ const stubsRef = useRef(fileStubs);
+ stubsRef.current = fileStubs;
// Per-file (dispatchKey) count of consecutive queue-rejection retries, so backoff escalates and
// eventually gives up. Survives the run-id changing on each retry; reset on any real outcome.
const queueRetries = useRef