Classifier Policy (#6898)
## Overview Adds **AI document classification** and a **classification-aware Files sidebar**: uploaded documents are automatically tagged with document-type labels (Invoice, Contract, Lab report, …), and the sidebar groups files under editable parent categories so a large library stays navigable. > [!IMPORTANT] > **This feature only runs in the SaaS build.** Classification depends on the AI engine and team-scoped label storage, so it's gated to SaaS end-to-end: > - The sidebar grouping is a `saas/`-layer override of the `fileSidebarGrouping` seam; every other build (OSS core, self-hosted proprietary, desktop) gets the null stub and renders the **unchanged flat, recency-sorted list** — no categories, no "Other", no picker. > - The classify/labels backend endpoints are gated on `policies.enabled` (on in SaaS) and live in `app/proprietary`, so they're absent from pure OSS and dormant in self-hosted unless explicitly enabled. > - The Python classifier is reached only via that gated path. > > Shared-layer changes that do compile everywhere are inert without the engine (dormant schema/field additions) or intentional (`GetInfoOnPDF` surfacing custom metadata). ## What it does - **Classifier (engine):** reads the first/last two pages of a PDF and assigns document-type labels from an allowed vocabulary. Labels are deliberately document-*type* descriptors — no deep-content/PII detection, since only a page window is read. - **Team label vocabulary:** ~270 built-in defaults across ~15 families, seeded per team. Editable by team leaders/admins in the Classification policy settings (import/export/reset). Team-scoped and shared; **per-user personal labels are intentionally out of scope** — the vocabulary is team-level only. - **Sidebar categories:** files group under parent categories (Financial, Legal, Medical, …), busiest-first, collapsible, with a "Recent" group on top and an "Other" group for anything uncategorised. The category structure (names, icons, membership, custom categories) is **device-local and user-editable** via a "Customize" picker — the only per-user personalization; it never changes the team's label vocabulary. - Classification results are written to PDF metadata (`StirlingPDFClassification`), read back to keep files in their groups without re-parsing. ## Architecture Spans all three layers, mirroring the existing policy/source subsystem conventions: - **`frontend/editor`** — sidebar grouping seam + SaaS override, category manager, labels editor, icon palette, file grouping, tests, `en-US` i18n. - **`app/proprietary` + `app/common` + `app/core`** — `ClassifyLabelController`, team-scoped `ClassificationLabelStore` (Jpa + in-process impls, same shape as `PolicyStore`/`SourceStore`), metadata read/write. - **`engine`** — the document-classifier agent, contracts, routes, tests. ## Screenshots **Files sidebar — grouped by category (SaaS)** ### Loading view <img width="2056" height="1046" alt="Screenshot 2026-07-07 at 5 12 56 PM" src="https://github.com/user-attachments/assets/1d712da5-50ae-4349-b0cd-e62665c3ec0c" /> ### Organized in the sidebar <img width="2056" height="1045" alt="Screenshot 2026-07-07 at 5 14 05 PM" src="https://github.com/user-attachments/assets/3ea4fe21-da51-4cea-bc3a-18ce040d3d05" /> **Customize categories picker** ### Personal settings to change how labels are grouped in an individual users editor <img width="2056" height="1044" alt="Screenshot 2026-07-07 at 5 52 42 PM" src="https://github.com/user-attachments/assets/40be03ce-0f63-4d1e-b58b-cec045d01cb2" /> **Classification labels editor (team settings)** <img width="2056" height="1042" alt="Screenshot 2026-07-07 at 5 53 00 PM" src="https://github.com/user-attachments/assets/337b0739-15c9-4749-9c6b-22e3b20825b8" /> ## Testing - Frontend `task frontend:check` — green (editor + portal tests, typecheck across all flavors, lint, label-drift guard). - Backend `task backend:check` (proprietary) and `:saas:test` — green. - Engine `task engine:check` — green.
This commit is contained in:
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +18,9 @@ import stirling.software.common.model.PdfMetadata;
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/** ({@code {labels}}). Written by the classify-and-label tool. */
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
@@ -177,4 +181,14 @@ public class PdfMetadataService {
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
|
||||
*/
|
||||
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
|
||||
pdf.setDocumentInformation(info);
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -305,6 +305,23 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
|
||||
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
|
||||
* entry).
|
||||
*/
|
||||
private static final java.util.Set<String> STANDARD_INFO_KEYS =
|
||||
java.util.Set.of(
|
||||
"Title",
|
||||
"Author",
|
||||
"Subject",
|
||||
"Keywords",
|
||||
"Producer",
|
||||
"Creator",
|
||||
"CreationDate",
|
||||
"ModDate",
|
||||
"Trapped");
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document) {
|
||||
ObjectNode metadata = objectMapper.createObjectNode();
|
||||
|
||||
@@ -335,6 +352,18 @@ public class GetInfoOnPDF {
|
||||
if (modificationDate != null) {
|
||||
metadata.put("ModificationDate", modificationDate);
|
||||
}
|
||||
|
||||
// Surface custom Info-dictionary entries (anything beyond the
|
||||
// standard fields above) — e.g. StirlingPDFClassification
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
if (STANDARD_INFO_KEYS.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = info.getCustomMetadataValue(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting metadata: {}", e.getMessage());
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.model.LabelsValidator;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
|
||||
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
|
||||
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
|
||||
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
|
||||
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
|
||||
* default on the backend or the engine — the label data lives only in the frontend).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/labels")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class ClassificationLabelsController {
|
||||
|
||||
private final ClassificationLabelStore labelStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification labels",
|
||||
description =
|
||||
"Returns the caller's team label set, or 204 when the team has none (its"
|
||||
+ " documents are then not classified).")
|
||||
public ResponseEntity<ClassificationLabels> getTeamLabels() {
|
||||
return labelStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification labels",
|
||||
description =
|
||||
"Validates and stores the label set for the caller's team, shared by everyone"
|
||||
+ " on the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationLabels> saveTeamLabels(
|
||||
@RequestBody ClassificationLabels labels) {
|
||||
requireEditingAllowed();
|
||||
validate(labels);
|
||||
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification labels",
|
||||
description =
|
||||
"Removes the team's stored label set; its documents are then not classified"
|
||||
+ " until labels are saved again. Requires the policy-editor role for the"
|
||||
+ " team.")
|
||||
public ResponseEntity<Void> resetTeamLabels() {
|
||||
requireEditingAllowed();
|
||||
labelStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static void validate(ClassificationLabels labels) {
|
||||
try {
|
||||
LabelsValidator.validate(labels);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the team labels requires the editor role for the caller's team — the same gate
|
||||
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The team classification labels may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's team key. With login disabled the single operator owns the {@link
|
||||
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
|
||||
* is an error rather than being dropped into the shared sentinel bucket (which would let
|
||||
* unteamed users read and overwrite each other's "team" labels).
|
||||
*/
|
||||
private Long currentTeamId() {
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
if (teamId != null) {
|
||||
return teamId;
|
||||
}
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return TeamLabelsEntity.NO_TEAM;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
/**
|
||||
* One entry in the classification vocabulary. {@code id} is the label's stable identity (a slug,
|
||||
* unique within a set): it is what the engine returns and what is stored on the document. {@code
|
||||
* name} is the human display text the classifier model reasons over. {@code icon} is an optional
|
||||
* presentational key (a Material Symbols name shown in the file sidebar); the engine never sees it.
|
||||
*/
|
||||
public record ClassificationLabel(String id, String name, String icon) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
|
||||
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
|
||||
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
|
||||
* neither the backend nor the engine holds a default of its own.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
public ClassificationLabels {
|
||||
labels = labels == null ? List.of() : List.copyOf(labels);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
|
||||
*/
|
||||
public final class LabelsValidator {
|
||||
|
||||
private LabelsValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
|
||||
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
|
||||
// exhaust memory on deserialize.
|
||||
static final int MAX_LABELS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
|
||||
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
|
||||
// arbitrary
|
||||
// text that would render as garbage (or worse) in every teammate's sidebar.
|
||||
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationLabels labels) {
|
||||
if (labels == null || labels.labels() == null) {
|
||||
throw new IllegalArgumentException("Labels are required");
|
||||
}
|
||||
if (labels.labels().size() > MAX_LABELS) {
|
||||
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ClassificationLabel label : labels.labels()) {
|
||||
requireText(label.id(), "Label id");
|
||||
requireText(label.name(), "Label name");
|
||||
if (label.icon() != null && !label.icon().isEmpty()) {
|
||||
if (label.icon().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
if (!ICON_KEY.matcher(label.icon()).matches()) {
|
||||
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
|
||||
}
|
||||
}
|
||||
if (!ids.add(label.id().trim())) {
|
||||
throw new IllegalArgumentException("Duplicate label id: " + label.id());
|
||||
}
|
||||
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Duplicate label name: " + label.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.trim().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
|
||||
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface ClassificationLabelStore {
|
||||
|
||||
/** The team's stored labels, or empty when it has none (callers then skip classification). */
|
||||
Optional<ClassificationLabels> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's labels. Returns the stored value. */
|
||||
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
|
||||
|
||||
/** Remove the team's labels (reset to default). Returns whether a set existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
|
||||
* JpaClassificationLabelStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
byTeam.put(key(teamId), labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
|
||||
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
|
||||
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
|
||||
* TeamLabelsEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final TeamLabelsRepository teamRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return teamRepository
|
||||
.findById(key(teamId))
|
||||
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
TeamLabelsEntity entity = new TeamLabelsEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
teamRepository.save(entity);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!teamRepository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
teamRepository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<ClassificationLabels> parse(String json, String owner) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored label set that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller treats the team as having no labels (and skips
|
||||
// classification) rather than surfacing a 500 on every upload.
|
||||
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
|
||||
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_labels")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamLabelsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "labels_json", columnDefinition = "text")
|
||||
private String labelsJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import 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.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the caller's team 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.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class ClassifyLabelController {
|
||||
|
||||
/** Pages read from each end of the document — mirrors the engine's window. */
|
||||
private static final int WINDOW_PAGES = 2;
|
||||
|
||||
private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case there are no team labels to
|
||||
* classify against and the document is passed through unlabelled.
|
||||
*/
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@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.")
|
||||
public ResponseEntity<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against (the team stored no labels): pass the file
|
||||
// through unlabelled rather than ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
List<AiPageText> pages = extractWindow(document);
|
||||
String requestBody =
|
||||
objectMapper.writeValueAsString(
|
||||
new ClassifyEngineRequest(fileName, pages, allowed));
|
||||
|
||||
String userId = userService != null ? userService.getCurrentUsername() : null;
|
||||
String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId);
|
||||
|
||||
pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson));
|
||||
log.debug("[classify-and-label] labelled {} ({} window pages)", fileName, pages.size());
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
String text = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
|
||||
if (text != null && !text.isBlank()) {
|
||||
pages.add(new AiPageText(pageNumber, text));
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** First and last {@code window} page numbers (1-based), de-duplicated and in order. */
|
||||
static List<Integer> windowPageNumbers(int pageCount, int window) {
|
||||
Set<Integer> numbers = new LinkedHashSet<>();
|
||||
for (int page = 1; page <= Math.min(window, pageCount); page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
for (int page = Math.max(1, pageCount - window + 1); page <= pageCount; page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
return new ArrayList<>(numbers);
|
||||
}
|
||||
|
||||
/** Drop the transport-only {@code outcome} discriminator; keep the rest verbatim. */
|
||||
private String toMetadataValue(String engineResponseJson) {
|
||||
JsonNode node = objectMapper.readTree(engineResponseJson);
|
||||
if (node instanceof ObjectNode object) {
|
||||
object.remove("outcome");
|
||||
}
|
||||
return objectMapper.writeValueAsString(node);
|
||||
}
|
||||
|
||||
private static String safeFileName(String originalFilename) {
|
||||
String name = Filenames.toSimpleFileName(originalFilename);
|
||||
return (name == null || name.isBlank()) ? "classified.pdf" : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
|
||||
* The engine shows the model the names and returns the ids (icons are presentational and never
|
||||
* sent). Returns an empty list — the caller then skips classification — when the policy
|
||||
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
|
||||
* default vocabulary of its own, so a team's stored labels are the only source.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
private static void collectLabels(
|
||||
List<ClassificationLabel> labels, Map<String, EngineLabel> into) {
|
||||
for (ClassificationLabel label : labels) {
|
||||
if (label.id() == null
|
||||
|| label.id().isBlank()
|
||||
|| label.name() == null
|
||||
|| label.name().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
into.putIfAbsent(label.id(), new EngineLabel(label.id(), label.name()));
|
||||
}
|
||||
}
|
||||
|
||||
/** One allowed label sent to the engine: stable id + the name the model reasons over. */
|
||||
private record EngineLabel(String id, String name) {}
|
||||
|
||||
/** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */
|
||||
private record ClassifyEngineRequest(
|
||||
String fileName, List<AiPageText> pages, List<EngineLabel> labels) {}
|
||||
}
|
||||
+7
@@ -21,4 +21,11 @@ public class AiWorkflowResultFile {
|
||||
|
||||
@Schema(description = "MIME type of the file", example = "application/pdf")
|
||||
private String contentType;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Index into the request's fileInputs that this output was derived from, or null"
|
||||
+ " when it has no single source (e.g. a merge, or a generated file)."
|
||||
+ " Lets the client replace that input in place as a new version.")
|
||||
private Integer sourceIndex;
|
||||
}
|
||||
|
||||
+15
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
@@ -213,6 +214,20 @@ public class PolicyController {
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@Operation(
|
||||
summary = "Set the team's policy run order",
|
||||
description =
|
||||
"Persists the team-wide order policies run in, from the given ordered list of"
|
||||
+ " policy ids (position → order). The per-trigger order shown in the UI"
|
||||
+ " is this one sequence filtered by trigger. Team-leader/admin only;"
|
||||
+ " ids outside the caller's team are ignored.")
|
||||
public ResponseEntity<Void> reorderPolicies(@RequestBody List<String> orderedPolicyIds) {
|
||||
requirePolicyEditingAllowed();
|
||||
policyStore.reorder(policyAccessGuard.teamForNewPolicy(), orderedPolicyIds);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every {@code sourceId} a policy references must resolve to a source in the caller's team, so
|
||||
* a client can neither reference a non-existent source nor reach across teams to use another
|
||||
|
||||
+7
-3
@@ -8,7 +8,11 @@ import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
||||
* null if no step produced one.
|
||||
* {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline
|
||||
* inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge
|
||||
* combining several inputs, or a generated file). Callers use it to map an output back onto the
|
||||
* file it came from. {@code report}/{@code reportTool} carry the last step's structured report and
|
||||
* its operation, or null if no step produced one.
|
||||
*/
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
public record PolicyExecutionResult(
|
||||
List<Resource> files, List<Integer> origins, JsonNode report, String reportTool) {}
|
||||
|
||||
+40
-9
@@ -58,6 +58,11 @@ public class PolicyExecutor {
|
||||
// payload the tool surfaced alongside or instead of a file.
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
// A step's output files paired with each file's origin (the index into the original pipeline
|
||||
// inputs it traces back to, or null when it has no single source). Origins compose across steps
|
||||
// so the final result can be mapped back onto the files that entered the pipeline.
|
||||
private record StepOutput(List<Resource> files, List<Integer> origins, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
||||
* inputs} bind to named file fields and never enter the document stream.
|
||||
@@ -75,6 +80,12 @@ public class PolicyExecutor {
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Seed each input with its own index as origin; steps carry these through so the final
|
||||
// outputs can be traced back to the files that entered the pipeline.
|
||||
List<Integer> currentOrigins = new ArrayList<>();
|
||||
for (int k = 0; k < currentFiles.size(); k++) {
|
||||
currentOrigins.add(k);
|
||||
}
|
||||
// Last non-null report wins: the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
@@ -87,8 +98,10 @@ public class PolicyExecutor {
|
||||
"Pipeline step " + (i + 1) + " has no operation");
|
||||
}
|
||||
listener.onStepStart(i + 1, steps.size(), operation);
|
||||
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
|
||||
StepOutput stepResult =
|
||||
executeStep(step, currentFiles, currentOrigins, supportingFiles);
|
||||
currentFiles = stepResult.files();
|
||||
currentOrigins = stepResult.origins();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = operation;
|
||||
@@ -96,7 +109,7 @@ public class PolicyExecutor {
|
||||
listener.onStepComplete(i + 1, steps.size(), operation);
|
||||
}
|
||||
|
||||
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
|
||||
return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,32 +117,50 @@ public class PolicyExecutor {
|
||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
||||
* dispatch the first non-null report wins.
|
||||
*/
|
||||
private ToolResult executeStep(
|
||||
private StepOutput executeStep(
|
||||
PipelineStep step,
|
||||
List<Resource> inputFiles,
|
||||
List<Integer> inputOrigins,
|
||||
Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
requireAcceptedTypes(step.operation(), inputFiles);
|
||||
List<Resource> files = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(step.operation())) {
|
||||
// One call over all inputs. The outputs derive from a single input only when exactly
|
||||
// one entered; otherwise (a genuine merge) there is no single source.
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
files.addAll(r.files());
|
||||
Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null;
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
report = r.report();
|
||||
} else if (inputFiles.isEmpty()) {
|
||||
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(null);
|
||||
}
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
// One call per file: every output of this call inherits that input's origin, so a 1:1
|
||||
// op keeps its chain and a split (one input, many outputs) tags each output with the
|
||||
// same source.
|
||||
for (int k = 0; k < inputFiles.size(); k++) {
|
||||
Integer origin = inputOrigins.get(k);
|
||||
ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ToolResult(files, report);
|
||||
return new StepOutput(files, origins, report);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+34
-1
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -16,6 +17,8 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
public class InProcessPolicyStore implements PolicyStore {
|
||||
|
||||
private final Map<String, Policy> policies = new ConcurrentHashMap<>();
|
||||
// Run-order position per policy id, mirroring JpaPolicyStore's sort_order column.
|
||||
private final Map<String, Integer> sortOrders = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Policy save(Policy policy) {
|
||||
@@ -35,9 +38,25 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
policy.output(),
|
||||
policy.teamId());
|
||||
policies.put(id, stored);
|
||||
// Existing policy keeps its position; a new one appends to the end of its team's queue.
|
||||
sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId()));
|
||||
return stored;
|
||||
}
|
||||
|
||||
private int nextSortOrder(Long teamId) {
|
||||
return policies.values().stream()
|
||||
.filter(policy -> Objects.equals(policy.teamId(), teamId))
|
||||
.map(policy -> sortOrders.getOrDefault(policy.id(), 0))
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(-1)
|
||||
+ 1;
|
||||
}
|
||||
|
||||
private Comparator<Policy> byRunOrder() {
|
||||
return Comparator.<Policy>comparingInt(policy -> sortOrders.getOrDefault(policy.id(), 0))
|
||||
.thenComparing(Policy::id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Policy> get(String id) {
|
||||
return Optional.ofNullable(policies.get(id));
|
||||
@@ -45,13 +64,14 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
|
||||
@Override
|
||||
public List<Policy> all() {
|
||||
return List.copyOf(policies.values());
|
||||
return policies.values().stream().sorted(byRunOrder()).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Policy> findByTeam(Long teamId) {
|
||||
return policies.values().stream()
|
||||
.filter(policy -> Objects.equals(policy.teamId(), teamId))
|
||||
.sorted(byRunOrder())
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -64,8 +84,21 @@ public class InProcessPolicyStore implements PolicyStore {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reorder(Long teamId, List<String> orderedIds) {
|
||||
int position = 0;
|
||||
for (String id : orderedIds) {
|
||||
Policy policy = policies.get(id);
|
||||
if (policy == null || !Objects.equals(policy.teamId(), teamId)) {
|
||||
continue;
|
||||
}
|
||||
sortOrders.put(id, position++);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String id) {
|
||||
sortOrders.remove(id);
|
||||
return policies.remove(id) != null;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -1,11 +1,13 @@
|
||||
package stirling.software.proprietary.policy.store;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -26,6 +28,7 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Policy save(Policy policy) {
|
||||
String id =
|
||||
policy.id() == null || policy.id().isBlank()
|
||||
@@ -50,11 +53,49 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
entity.setEnabled(stored.enabled());
|
||||
entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type());
|
||||
entity.setTeamId(stored.teamId());
|
||||
// Preserve an existing policy's run-order position; append a new one to the end of its
|
||||
// team's queue (max + 1), so setting up a policy adds it last by default.
|
||||
entity.setSortOrder(
|
||||
repository
|
||||
.findById(id)
|
||||
.map(PolicyEntity::getSortOrder)
|
||||
.orElseGet(() -> nextSortOrder(stored.teamId())));
|
||||
entity.setPolicyJson(objectMapper.writeValueAsString(stored));
|
||||
repository.save(entity);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append position for a new policy: max(existing) + 1, computed under a pessimistic lock on the
|
||||
* team's rows (see {@link PolicyRepository#findByTeamForUpdate}) so two concurrent creates
|
||||
* can't both read the same max and assign a duplicate order. (A brand-new team has no rows to
|
||||
* lock; a rare simultaneous first-create there ties at 0 — harmless, since the ordering query
|
||||
* breaks ties by id and any later reorder normalises it.)
|
||||
*/
|
||||
private int nextSortOrder(Long teamId) {
|
||||
return repository.findByTeamForUpdate(teamId).stream()
|
||||
.map(entity -> entity.getSortOrder() == null ? 0 : entity.getSortOrder())
|
||||
.max(Integer::compareTo)
|
||||
.orElse(-1)
|
||||
+ 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void reorder(Long teamId, List<String> orderedIds) {
|
||||
int position = 0;
|
||||
for (String id : orderedIds) {
|
||||
PolicyEntity entity = repository.findById(id).orElse(null);
|
||||
// Ignore unknown ids and any policy outside the caller's team — a reorder can't reach
|
||||
// across teams.
|
||||
if (entity == null || !Objects.equals(entity.getTeamId(), teamId)) {
|
||||
continue;
|
||||
}
|
||||
entity.setSortOrder(position++);
|
||||
repository.save(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Policy> get(String id) {
|
||||
return repository.findById(id).map(this::toPolicy);
|
||||
@@ -62,7 +103,7 @@ public class JpaPolicyStore implements PolicyStore {
|
||||
|
||||
@Override
|
||||
public List<Policy> all() {
|
||||
return repository.findAll().stream().map(this::toPolicy).toList();
|
||||
return repository.findAllOrdered().stream().map(this::toPolicy).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+8
@@ -47,6 +47,14 @@ public class PolicyEntity implements Serializable {
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
/**
|
||||
* Position in the team's run order (ascending). Team-wide and admin-editable; the per-trigger
|
||||
* order the UI shows is this single sequence filtered by trigger. New policies are appended
|
||||
* (max + 1). Nullable for pre-existing rows; treated as 0 when sorting.
|
||||
*/
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "policy_json", columnDefinition = "text")
|
||||
private String policyJson;
|
||||
}
|
||||
|
||||
+24
-4
@@ -3,10 +3,13 @@ package stirling.software.proprietary.policy.store;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
@Repository
|
||||
public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
|
||||
|
||||
@@ -14,12 +17,29 @@ public interface PolicyRepository extends JpaRepository<PolicyEntity, String> {
|
||||
List<PolicyEntity> findByTriggerTypeAndEnabledTrue(String triggerType);
|
||||
|
||||
/**
|
||||
* Policies belonging to a team, loaded without scanning every team's rows. A {@code null}
|
||||
* teamId matches the rows with no team (login-disabled / pre-team data), mirroring the
|
||||
* in-memory team filter rather than the empty result a plain {@code = null} would give.
|
||||
* Policies belonging to a team, in run order (ascending {@code sortOrder}; a null order sorts
|
||||
* first, id breaks ties for stability). A {@code null} teamId matches the rows with no team
|
||||
* (login-disabled / pre-team data), mirroring the in-memory team filter rather than the empty
|
||||
* result a plain {@code = null} would give.
|
||||
*/
|
||||
@Query(
|
||||
"select p from PolicyEntity p where ((:teamId is null and p.teamId is null) or"
|
||||
+ " p.teamId = :teamId) order by coalesce(p.sortOrder, 0) asc, p.id asc")
|
||||
List<PolicyEntity> findByTeam(@Param("teamId") Long teamId);
|
||||
|
||||
/** All policies in run order — used when team scoping is off (login-disabled). */
|
||||
@Query("select p from PolicyEntity p order by coalesce(p.sortOrder, 0) asc, p.id asc")
|
||||
List<PolicyEntity> findAllOrdered();
|
||||
|
||||
/**
|
||||
* The team's policy rows, locked for the transaction (SELECT … FOR UPDATE). Appending a new
|
||||
* policy reads the max {@code sortOrder} from these under the lock, so two concurrent creates
|
||||
* serialize instead of both reading a stale max and assigning the same position. Must be called
|
||||
* inside a transaction.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"select p from PolicyEntity p where (:teamId is null and p.teamId is null) or"
|
||||
+ " p.teamId = :teamId")
|
||||
List<PolicyEntity> findByTeam(@Param("teamId") Long teamId);
|
||||
List<PolicyEntity> findByTeamForUpdate(@Param("teamId") Long teamId);
|
||||
}
|
||||
|
||||
+7
@@ -21,6 +21,13 @@ public interface PolicyStore {
|
||||
/** Enabled policies with the given trigger type, for background triggers. */
|
||||
List<Policy> findByTriggerType(String triggerType);
|
||||
|
||||
/**
|
||||
* Set the team's run order from {@code orderedIds} (position → sortOrder). Only policies that
|
||||
* belong to {@code teamId} are touched; unknown/other-team ids are ignored, so a caller can't
|
||||
* reorder across teams. Ids omitted from the list keep their existing order.
|
||||
*/
|
||||
void reorder(Long teamId, List<String> orderedIds);
|
||||
|
||||
/** Returns whether the policy existed. */
|
||||
boolean delete(String id);
|
||||
}
|
||||
|
||||
+4
-2
@@ -36,7 +36,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -47,7 +48,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model"
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+33
-7
@@ -404,6 +404,7 @@ public class AiWorkflowService {
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
@@ -465,7 +466,8 @@ public class AiWorkflowService {
|
||||
}
|
||||
};
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
|
||||
buildCompletedResponse(
|
||||
response.getSummary(), List.of(resource), null, List.of(), null));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -523,7 +525,11 @@ public class AiWorkflowService {
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary, result.files(), inputFileNames(filesById), result.report()));
|
||||
summary,
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -612,19 +618,35 @@ public class AiWorkflowService {
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary,
|
||||
List<Resource> resultFiles,
|
||||
List<Integer> origins,
|
||||
List<String> inputFileNames,
|
||||
JsonNode report)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
|
||||
// Count outputs per source so only a clean 1:1 transform (one output for a source) reuses
|
||||
// the input's name; a split (one input → many outputs) keeps each entry's own name.
|
||||
Map<Integer, Long> outputsPerOrigin =
|
||||
origins == null
|
||||
? Map.of()
|
||||
: origins.stream()
|
||||
.filter(o -> o != null)
|
||||
.collect(Collectors.groupingBy(o -> o, Collectors.counting()));
|
||||
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
|
||||
for (int i = 0; i < resultFiles.size(); i++) {
|
||||
Resource resource = resultFiles.get(i);
|
||||
String responseName = resource.getFilename();
|
||||
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
|
||||
// Prefer the input name only for 1:1 operations where the output keeps the same
|
||||
// extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// The output's source input (from the executor), used both to name it and to tell the
|
||||
// client which file to version in place.
|
||||
Integer origin = origins != null && i < origins.size() ? origins.get(i) : null;
|
||||
boolean uniqueOrigin =
|
||||
origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L;
|
||||
String inputName =
|
||||
uniqueOrigin && origin >= 0 && origin < inputFileNames.size()
|
||||
? inputFileNames.get(origin)
|
||||
: null;
|
||||
// Prefer the source input's name only for 1:1 operations where the output keeps the
|
||||
// same extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// tools, the response filename from Content-Disposition is authoritative.
|
||||
String name;
|
||||
if (inputName != null
|
||||
@@ -644,7 +666,11 @@ public class AiWorkflowService {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
fileId = fileStorage.storeInputStream(is, name).fileId();
|
||||
}
|
||||
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
|
||||
// Only expose the source when this is a clean 1:1 transform, so the client can treat a
|
||||
// present sourceIndex as "replace that input in place" without further disambiguation.
|
||||
descriptors.add(
|
||||
new AiWorkflowResultFile(
|
||||
fileId, name, contentType, uniqueOrigin ? origin : null));
|
||||
}
|
||||
|
||||
AiWorkflowResponse completed = new AiWorkflowResponse();
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ClassificationLabelsController")
|
||||
class ClassificationLabelsControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private ClassificationLabelStore store;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ClassificationLabelsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessClassificationLabelStore();
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new ClassificationLabelsController(
|
||||
store, policyManagementAuthority, applicationProperties, userService);
|
||||
}
|
||||
|
||||
private static ClassificationLabels sample() {
|
||||
return new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null)));
|
||||
}
|
||||
|
||||
private void loginEnabled(boolean enabled) {
|
||||
applicationProperties.getSecurity().setEnableLogin(enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET returns 204 when the team has no labels")
|
||||
void getEmpty() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
|
||||
void saveThenGet() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
controller.saveTeamLabels(sample());
|
||||
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
|
||||
|
||||
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(got.getBody()).isNotNull();
|
||||
assertThat(got.getBody().labels()).hasSize(2);
|
||||
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
|
||||
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is scoped per team")
|
||||
void perTeam() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is rejected for a non-editor when login is enabled")
|
||||
void putForbiddenForNonEditor() {
|
||||
loginEnabled(true);
|
||||
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT rejects an invalid label set with 400")
|
||||
void putInvalid() {
|
||||
loginEnabled(false);
|
||||
ClassificationLabels duplicate =
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE resets the team back to no stored labels")
|
||||
void deleteResets() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
ResponseEntity<Void> response = controller.resetTeamLabels();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("LabelsValidator")
|
||||
class LabelsValidatorTest {
|
||||
|
||||
private static ClassificationLabels labels(ClassificationLabel... labels) {
|
||||
return new ClassificationLabels(List.of(labels));
|
||||
}
|
||||
|
||||
private static ClassificationLabel label(String name) {
|
||||
return new ClassificationLabel(slug(name), name, null);
|
||||
}
|
||||
|
||||
private static String slug(String name) {
|
||||
return name.trim()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("(^-|-$)", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts a well-formed label set")
|
||||
void acceptsValid() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
label("Contract"));
|
||||
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts an empty label set (reads as: use the default)")
|
||||
void acceptsEmpty() {
|
||||
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null label set")
|
||||
void rejectsNull() {
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Labels are required");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names (distinct ids)")
|
||||
void rejectsDuplicateNames() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names differing only by case")
|
||||
void rejectsDuplicateNamesCaseInsensitive() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "INVOICE", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate ids")
|
||||
void rejectsDuplicateIds() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Sales invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank name")
|
||||
void rejectsBlankName() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label name must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank id")
|
||||
void rejectsBlankId() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label id must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long name")
|
||||
void rejectsOverLongName() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long icon (a null icon is fine)")
|
||||
void rejectsOverLongIcon() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"invoice",
|
||||
"Invoice",
|
||||
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("icon is too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects more labels than the cap")
|
||||
void rejectsTooManyLabels() {
|
||||
List<ClassificationLabel> tooMany =
|
||||
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
|
||||
.mapToObj(i -> label("label" + i))
|
||||
.toList();
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Too many labels");
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ClassifyLabelControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private InProcessClassificationLabelStore labelStore;
|
||||
private ClassifyLabelController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
labelStore = new InProcessClassificationLabelStore();
|
||||
controller =
|
||||
new ClassifyLabelController(
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
pdfContentExtractor,
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
null,
|
||||
labelStore,
|
||||
policyManagementAuthority);
|
||||
}
|
||||
|
||||
private void stubSinglePageDocument() throws Exception {
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
|
||||
when(document.getNumberOfPages()).thenReturn(1);
|
||||
when(pdfContentExtractor.extractPageTextRaw(document, 1))
|
||||
.thenReturn("Invoice total due 100.00");
|
||||
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
|
||||
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file);
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode sentEngineRequest() throws Exception {
|
||||
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||
verify(aiEngineClient).post(eq("/api/v1/documents/classify"), body.capture(), isNull());
|
||||
return objectMapper.readTree(body.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(new ClassificationLabel("invoice", "Invoice", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
ArgumentCaptor<String> value = ArgumentCaptor.forClass(String.class);
|
||||
verify(pdfMetadataService)
|
||||
.setClassificationMetadata(any(PDDocument.class), value.capture());
|
||||
|
||||
JsonNode written = objectMapper.readTree(value.getValue());
|
||||
assertThat(written.has("outcome")).isFalse();
|
||||
// The engine returns label ids; they're stored on the document verbatim.
|
||||
assertThat(written.get("labels").get(0).asText()).isEqualTo("invoice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
JsonNode request = sentEngineRequest();
|
||||
assertThat(request.get("fileName").asText()).isEqualTo("invoice.pdf");
|
||||
JsonNode labels = request.get("labels");
|
||||
assertThat(labels.isArray()).isTrue();
|
||||
assertThat(labels.size()).isEqualTo(3);
|
||||
// Each entry is an {id, name} pair — the model reasons over names, we store ids.
|
||||
assertThat(
|
||||
List.of(
|
||||
labels.get(0).get("id").asText(),
|
||||
labels.get(1).get("id").asText(),
|
||||
labels.get(2).get("id").asText()))
|
||||
.containsExactly("invoice", "contract", "timesheet");
|
||||
assertThat(
|
||||
List.of(
|
||||
labels.get(0).get("name").asText(),
|
||||
labels.get(1).get("name").asText(),
|
||||
labels.get(2).get("name").asText()))
|
||||
.containsExactly("Invoice", "Contract", "Timesheet");
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
// No team labels stored, and the engine holds no default of its own, so the file is passed
|
||||
// through unlabelled: neither the engine nor the metadata write is invoked.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never())
|
||||
.setClassificationMetadata(any(PDDocument.class), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowPageNumbers_takesFirstAndLastWithoutOverlap() {
|
||||
assertEquals(List.of(1, 2, 4, 5), ClassifyLabelController.windowPageNumbers(5, 2));
|
||||
assertEquals(List.of(1, 2, 3), ClassifyLabelController.windowPageNumbers(3, 2));
|
||||
// Short docs clamp + dedupe rather than throwing or going out of range.
|
||||
assertEquals(List.of(1, 2), ClassifyLabelController.windowPageNumbers(2, 2));
|
||||
assertEquals(List.of(1), ClassifyLabelController.windowPageNumbers(1, 2));
|
||||
assertEquals(List.of(), ClassifyLabelController.windowPageNumbers(0, 2));
|
||||
}
|
||||
}
|
||||
+52
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
@@ -230,6 +231,57 @@ class AiWorkflowServiceTest {
|
||||
// 1:1 mapping preserves each input's filename.
|
||||
assertEquals("a.pdf", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals("b.pdf", result.getResultFiles().get(1).getFileName());
|
||||
// Each output points back at the input it came from so the client versions it in place.
|
||||
assertEquals(0, result.getResultFiles().get(0).getSourceIndex());
|
||||
assertEquals(1, result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeOutputHasNoSourceIndex() throws IOException {
|
||||
MockMultipartFile a = pdf("a.pdf", "a-bytes");
|
||||
MockMultipartFile b = pdf("b.pdf", "b-bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"}
|
||||
"""
|
||||
.formatted(MERGE_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false);
|
||||
stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf"));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result =
|
||||
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these"));
|
||||
|
||||
// A merge draws on several inputs, so there is no single source to version in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitOutputsHaveNoSourceIndex() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf", "original");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"}
|
||||
"""
|
||||
.formatted(SPLIT_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true);
|
||||
stubEndpoint(
|
||||
SPLIT_ENDPOINT,
|
||||
zipResource(
|
||||
"doc.zip",
|
||||
List.of(
|
||||
new ZipEntryBytes("page-1.pdf", "page-one"),
|
||||
new ZipEntryBytes("page-2.pdf", "page-two"))));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "split"));
|
||||
|
||||
// One input fanned out to many outputs, so none is a clean 1:1 version — the client adds
|
||||
// them as fresh files and leaves the original in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
assertNull(result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -65,6 +65,12 @@ public class TeamSecurityExpressions {
|
||||
return currentUser.getTeam().getId();
|
||||
}
|
||||
|
||||
/** The current authenticated user's id, or {@code null} if unauthenticated. */
|
||||
public Long currentUserId() {
|
||||
User currentUser = getCurrentUser();
|
||||
return currentUser == null ? null : currentUser.getId();
|
||||
}
|
||||
|
||||
/** Whether the current authenticated user is any kind of member of the given team. */
|
||||
public boolean isTeamMember(Long teamId) {
|
||||
User currentUser = getCurrentUser();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document
|
||||
-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in
|
||||
-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign
|
||||
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
|
||||
-- without touching them; the sentinel 0 holds the unteamed (login-disabled) team set. Hibernate
|
||||
-- ddl-auto would also create this, but this keeps the schema explicit for Flyway-managed deploys.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_labels (
|
||||
team_id BIGINT PRIMARY KEY,
|
||||
labels_json TEXT,
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(255)
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Team-wide policy run order (see PolicyEntity.sortOrder). The order policies run in is stored
|
||||
-- server-side and shared by the whole team (not per-user in the browser), and is admin-editable.
|
||||
-- Nullable so existing rows keep working; reads treat a null as 0 (coalesce), and the store
|
||||
-- appends a new policy at max(order)+1 so setting one up adds it to the end of the queue.
|
||||
ALTER TABLE policies ADD COLUMN sort_order INTEGER;
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Agent modules for Stirling AI reasoning flows."""
|
||||
|
||||
from .document_classifier import DocumentClassifierAgent
|
||||
from .execution import ExecutionPlanningAgent
|
||||
from .orchestrator import OrchestratorAgent
|
||||
from .pdf_create import PdfCreateAgent
|
||||
@@ -9,6 +10,7 @@ from .pdf_review import PdfReviewAgent
|
||||
from .user_spec import UserSpecAgent
|
||||
|
||||
__all__ = [
|
||||
"DocumentClassifierAgent",
|
||||
"ExecutionPlanningAgent",
|
||||
"OrchestratorAgent",
|
||||
"PdfCreateAgent",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
from stirling.contracts import (
|
||||
ClassifyDocumentRequest,
|
||||
ClassifyDocumentResponse,
|
||||
DocumentClassificationResponse,
|
||||
LabelOption,
|
||||
PageText,
|
||||
)
|
||||
from stirling.models import ApiModel
|
||||
from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A document carries at most this many labels; anything past the cap is dropped
|
||||
# in the model's order.
|
||||
MAX_ASSIGNED_LABELS = 5
|
||||
# Pages read from each end of the document. A document's type is evident from
|
||||
# its opening (and closing) pages, so a fixed window keeps cost and latency flat
|
||||
# regardless of length. Promote to AppSettings if it ever needs tuning.
|
||||
WINDOW_PAGES = 2
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"You identify what a document is by assigning labels, choosing only from a "
|
||||
"fixed list of allowed labels you are given.\n"
|
||||
"\n"
|
||||
"Rules:\n"
|
||||
f"- Pick up to {MAX_ASSIGNED_LABELS} labels that describe this document's type.\n"
|
||||
"- Only use labels from the allowed list, spelled exactly as listed.\n"
|
||||
"- Return an empty list if none fit.\n"
|
||||
"- Judge from the document's content and structure, not from keywords alone. "
|
||||
"The document may be in any language.\n"
|
||||
"- You are shown only the first and last pages; that is enough to identify the type."
|
||||
)
|
||||
|
||||
|
||||
class _ClassifierOutput(ApiModel):
|
||||
"""Raw model answer, before it is validated against the allowed vocabulary."""
|
||||
|
||||
labels: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Labels from the allowed list that describe this document's type.",
|
||||
)
|
||||
|
||||
|
||||
def render_labels(labels: list[LabelOption]) -> str:
|
||||
"""Render the allowed vocabulary for the prompt — the model reasons over the
|
||||
human names (it never sees ids)."""
|
||||
names = [label.name for label in labels]
|
||||
return f"Allowed labels: {', '.join(names) or '(none)'}"
|
||||
|
||||
|
||||
def select_window(pages: list[PageText], window: int = WINDOW_PAGES) -> list[PageText]:
|
||||
"""Return the first and last ``window`` pages, never overlapping.
|
||||
|
||||
Documents short enough that the two ends would meet are returned whole. The
|
||||
caller usually sends just the window already; this is a defensive trim in
|
||||
case it sends more.
|
||||
"""
|
||||
if window <= 0 or len(pages) <= window * 2:
|
||||
return list(pages)
|
||||
return [*pages[:window], *pages[-window:]]
|
||||
|
||||
|
||||
def format_window(pages: list[PageText]) -> str:
|
||||
if not pages:
|
||||
return "(no extractable text)"
|
||||
return "\n\n".join(f"[Page {page.page_number}]\n{page.text}" for page in pages)
|
||||
|
||||
|
||||
def validate_labels(output: _ClassifierOutput, allowed: list[LabelOption]) -> DocumentClassificationResponse:
|
||||
"""Coerce a raw model answer (names) onto the allowed vocabulary, returning
|
||||
label **ids**.
|
||||
|
||||
The model answers with names; they are matched case-insensitively to the
|
||||
allowed vocabulary and returned as that label's id. Anything off-list is
|
||||
dropped, duplicates collapse to the first occurrence, and the result is
|
||||
capped at ``MAX_ASSIGNED_LABELS`` in the model's order. The model identifies;
|
||||
these rules decide what is allowed to stand.
|
||||
"""
|
||||
id_by_lower_name = {label.name.lower(): label.id for label in allowed}
|
||||
|
||||
kept: list[str] = []
|
||||
for name in output.labels:
|
||||
label_id = id_by_lower_name.get(name.strip().lower())
|
||||
if label_id is not None and label_id not in kept:
|
||||
kept.append(label_id)
|
||||
if len(kept) == MAX_ASSIGNED_LABELS:
|
||||
break
|
||||
|
||||
return DocumentClassificationResponse(labels=kept)
|
||||
|
||||
|
||||
class DocumentClassifierAgent:
|
||||
"""Assigns labels to a document from an allowed vocabulary.
|
||||
|
||||
Reads the bounded page window supplied on the request (first/last
|
||||
``WINDOW_PAGES``) and runs a single fast-model pass, then validates the
|
||||
answer against the vocabulary so nothing off-list survives.
|
||||
"""
|
||||
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self._agent = Agent(
|
||||
model=runtime.fast_model,
|
||||
output_type=NativeOutput(_ClassifierOutput),
|
||||
system_prompt=_SYSTEM_PROMPT,
|
||||
model_settings=runtime.fast_model_settings,
|
||||
)
|
||||
|
||||
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
|
||||
# The caller (the backend) always supplies the allowed vocabulary — the
|
||||
# team's stored labels — so the engine holds no vocabulary of its own.
|
||||
allowed = request.labels
|
||||
window = select_window(request.pages)
|
||||
prompt = self._build_prompt(request.file_name, allowed, window)
|
||||
logger.debug("[classify] prompt:\n%s", prompt)
|
||||
result = await self._agent.run(prompt)
|
||||
return validate_labels(result.output, allowed)
|
||||
|
||||
@staticmethod
|
||||
def _build_prompt(file_name: str, allowed: list[LabelOption], window: list[PageText]) -> str:
|
||||
return (
|
||||
f"{render_labels(allowed)}\n\n"
|
||||
f"Document file name: {file_name}\n"
|
||||
f"Document content (first and last pages):\n{format_window(window)}"
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from pydantic_ai import Agent
|
||||
from pydantic_ai.models.instrumented import InstrumentationSettings
|
||||
|
||||
from stirling.agents import (
|
||||
DocumentClassifierAgent,
|
||||
ExecutionPlanningAgent,
|
||||
OrchestratorAgent,
|
||||
PdfEditAgent,
|
||||
@@ -24,6 +25,7 @@ from stirling.api.middleware import UserIdMiddleware
|
||||
from stirling.api.routes import (
|
||||
agent_capabilities_router,
|
||||
agent_draft_router,
|
||||
document_classifier_router,
|
||||
document_router,
|
||||
execution_router,
|
||||
ledger_router,
|
||||
@@ -95,6 +97,7 @@ async def lifespan(fast_api: FastAPI):
|
||||
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
|
||||
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
|
||||
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
|
||||
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
|
||||
tracer_provider = setup_posthog_tracking(settings)
|
||||
if tracer_provider:
|
||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
|
||||
@@ -131,6 +134,7 @@ app.include_router(document_router, dependencies=_user_gate)
|
||||
app.include_router(ledger_router, dependencies=_user_gate)
|
||||
app.include_router(pdf_comments_router, dependencies=_user_gate)
|
||||
app.include_router(agent_capabilities_router, dependencies=_user_gate)
|
||||
app.include_router(document_classifier_router, dependencies=_user_gate)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Annotated
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from stirling.agents import (
|
||||
DocumentClassifierAgent,
|
||||
ExecutionPlanningAgent,
|
||||
OrchestratorAgent,
|
||||
PdfEditAgent,
|
||||
@@ -55,6 +56,10 @@ def get_pdf_comment_agent(request: Request) -> PdfCommentAgent:
|
||||
return request.app.state.pdf_comment_agent
|
||||
|
||||
|
||||
def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
|
||||
return request.app.state.document_classifier_agent
|
||||
|
||||
|
||||
def require_user_id() -> UserId:
|
||||
"""FastAPI dependency for routes that touch per-user storage.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .agent_capabilities import router as agent_capabilities_router
|
||||
from .agent_drafts import router as agent_draft_router
|
||||
from .document_classifier import router as document_classifier_router
|
||||
from .documents import router as document_router
|
||||
from .execution import router as execution_router
|
||||
from .ledger import router as ledger_router
|
||||
@@ -11,6 +12,7 @@ from .pdf_questions import router as pdf_question_router
|
||||
__all__ = [
|
||||
"agent_capabilities_router",
|
||||
"agent_draft_router",
|
||||
"document_classifier_router",
|
||||
"document_router",
|
||||
"execution_router",
|
||||
"ledger_router",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from stirling.agents import DocumentClassifierAgent
|
||||
from stirling.api.dependencies import get_document_classifier_agent
|
||||
from stirling.contracts import ClassifyDocumentRequest, ClassifyDocumentResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents/classify", tags=["document-classifier"])
|
||||
|
||||
|
||||
@router.post("", response_model=ClassifyDocumentResponse)
|
||||
async def classify_document(
|
||||
request: ClassifyDocumentRequest,
|
||||
agent: Annotated[DocumentClassifierAgent, Depends(get_document_classifier_agent)],
|
||||
) -> ClassifyDocumentResponse:
|
||||
"""Classify a document from its supplied page text against the allowed labels.
|
||||
|
||||
The caller sends the bounded page window inline, so no per-user document
|
||||
storage is touched here — the request is self-contained.
|
||||
"""
|
||||
return await agent.classify(request)
|
||||
@@ -35,6 +35,12 @@ from .contradiction import (
|
||||
ContradictionReport,
|
||||
ContradictionSeverity,
|
||||
)
|
||||
from .document_classifier import (
|
||||
ClassifyDocumentRequest,
|
||||
ClassifyDocumentResponse,
|
||||
DocumentClassificationResponse,
|
||||
LabelOption,
|
||||
)
|
||||
from .documents import (
|
||||
DeleteDocumentResponse,
|
||||
IngestDocumentRequest,
|
||||
@@ -128,6 +134,8 @@ __all__ = [
|
||||
"AiToolAgentStep",
|
||||
"ArtifactKind",
|
||||
"CannotContinueExecutionAction",
|
||||
"ClassifyDocumentRequest",
|
||||
"ClassifyDocumentResponse",
|
||||
"Claim",
|
||||
"CommentSpec",
|
||||
"CompletedExecutionAction",
|
||||
@@ -138,6 +146,8 @@ __all__ = [
|
||||
"DeleteDocumentResponse",
|
||||
"PurgeOwnerResponse",
|
||||
"Discrepancy",
|
||||
"DocumentClassificationResponse",
|
||||
"LabelOption",
|
||||
"DocumentMeta",
|
||||
"DocumentSections",
|
||||
"DiscrepancyKind",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from stirling.models import ApiModel
|
||||
|
||||
from .documents import PageText
|
||||
|
||||
|
||||
class LabelOption(ApiModel):
|
||||
"""One allowed label: a stable ``id`` (the identity) plus the human ``name``
|
||||
the model actually reasons over. The model picks names; the engine maps its
|
||||
answer back to ids, so the result is language- and rename-stable."""
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ClassifyDocumentRequest(ApiModel):
|
||||
"""Classify one document from its page text.
|
||||
|
||||
The caller sends the page text directly — typically just the bounded window
|
||||
(first/last pages), since the classifier reads no more than that. There is no
|
||||
ingestion or RAG step.
|
||||
|
||||
``labels`` is the allowed vocabulary for this request — the caller (the
|
||||
backend) always supplies it from the team's stored label set, each an
|
||||
``{id, name}`` pair. The engine holds no vocabulary of its own, so at least
|
||||
one label is required.
|
||||
"""
|
||||
|
||||
file_name: str = Field(min_length=1)
|
||||
pages: list[PageText] = Field(default_factory=list)
|
||||
labels: list[LabelOption] = Field(min_length=1)
|
||||
|
||||
|
||||
class DocumentClassificationResponse(ApiModel):
|
||||
"""Terminal classification result.
|
||||
|
||||
``labels`` is the subset of the allowed vocabulary the model assigned to the
|
||||
document, as label **ids**: at most five entries, deduplicated, in the
|
||||
model's order. An empty list is a valid answer — nothing in the vocabulary
|
||||
fit. This is a plain answer from a dedicated endpoint — it carries no
|
||||
``outcome`` discriminator (it isn't one of the orchestrator's
|
||||
WorkflowOutcome-routed union responses).
|
||||
"""
|
||||
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Only one response shape today; kept as a named alias so routes and agents have
|
||||
# a stable response type to import.
|
||||
ClassifyDocumentResponse = DocumentClassificationResponse
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.agents.document_classifier import (
|
||||
MAX_ASSIGNED_LABELS,
|
||||
DocumentClassifierAgent,
|
||||
_ClassifierOutput,
|
||||
render_labels,
|
||||
select_window,
|
||||
validate_labels,
|
||||
)
|
||||
from stirling.contracts import (
|
||||
ClassifyDocumentRequest,
|
||||
DocumentClassificationResponse,
|
||||
LabelOption,
|
||||
PageText,
|
||||
)
|
||||
from stirling.services.runtime import AppRuntime
|
||||
|
||||
|
||||
def _page(number: int, text: str = "x") -> PageText:
|
||||
return PageText(page_number=number, text=text)
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return re.sub(r"^-+|-+$", "", re.sub(r"[^a-z0-9]+", "-", name.lower()))
|
||||
|
||||
|
||||
def _opts(*names: str) -> list[LabelOption]:
|
||||
"""Allowed vocabulary from names, with slug ids (mirrors the frontend)."""
|
||||
return [LabelOption(id=_slug(name), name=name) for name in names]
|
||||
|
||||
|
||||
# ── select_window ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_select_window_returns_short_documents_whole() -> None:
|
||||
pages = [_page(1), _page(2), _page(3), _page(4)]
|
||||
assert select_window(pages, window=2) == pages
|
||||
|
||||
|
||||
def test_select_window_takes_both_ends_without_overlap() -> None:
|
||||
pages = [_page(n) for n in range(1, 6)] # 5 pages
|
||||
selected = select_window(pages, window=2)
|
||||
assert [p.page_number for p in selected] == [1, 2, 4, 5]
|
||||
|
||||
|
||||
def test_select_window_handles_empty() -> None:
|
||||
assert select_window([], window=2) == []
|
||||
|
||||
|
||||
def test_select_window_zero_returns_all() -> None:
|
||||
pages = [_page(1), _page(2), _page(3)]
|
||||
assert select_window(pages, window=0) == pages
|
||||
|
||||
|
||||
# ── validate_labels ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_labels_are_preserved_in_order() -> None:
|
||||
# Model answers with names; the result is the matching label ids.
|
||||
output = _ClassifierOutput(labels=["Invoice", "Receipt"])
|
||||
result = validate_labels(output, _opts("Invoice", "Receipt", "Purchase order"))
|
||||
assert isinstance(result, DocumentClassificationResponse)
|
||||
assert result.labels == ["invoice", "receipt"]
|
||||
|
||||
|
||||
def test_off_list_labels_are_dropped() -> None:
|
||||
output = _ClassifierOutput(labels=["Invoice", "Warp core", "Receipt"])
|
||||
result = validate_labels(output, _opts("Invoice", "Receipt"))
|
||||
assert result.labels == ["invoice", "receipt"]
|
||||
|
||||
|
||||
def test_matching_is_case_insensitive_and_returns_ids() -> None:
|
||||
output = _ClassifierOutput(labels=["invoice", " CREDIT NOTE "])
|
||||
result = validate_labels(output, _opts("Invoice", "Credit note"))
|
||||
assert result.labels == ["invoice", "credit-note"]
|
||||
|
||||
|
||||
def test_duplicates_collapse_to_first_occurrence() -> None:
|
||||
output = _ClassifierOutput(labels=["Invoice", "invoice", "Receipt", "INVOICE"])
|
||||
result = validate_labels(output, _opts("Invoice", "Receipt"))
|
||||
assert result.labels == ["invoice", "receipt"]
|
||||
|
||||
|
||||
def test_result_is_capped_at_max_assigned_labels() -> None:
|
||||
allowed = _opts(*[f"Label {n}" for n in range(10)])
|
||||
output = _ClassifierOutput(labels=[label.name for label in allowed])
|
||||
result = validate_labels(output, allowed)
|
||||
assert result.labels == [label.id for label in allowed[:MAX_ASSIGNED_LABELS]]
|
||||
|
||||
|
||||
def test_empty_answer_is_valid() -> None:
|
||||
result = validate_labels(_ClassifierOutput(labels=[]), _opts("Invoice"))
|
||||
assert result.labels == []
|
||||
|
||||
|
||||
def test_entirely_off_list_answer_yields_empty_result() -> None:
|
||||
output = _ClassifierOutput(labels=["Spaceship", "Boarding pass"])
|
||||
result = validate_labels(output, _opts("Invoice", "Receipt"))
|
||||
assert result.labels == []
|
||||
|
||||
|
||||
# ── render_labels ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_labels_lists_the_vocabulary_names() -> None:
|
||||
rendered = render_labels(_opts("Invoice", "Receipt"))
|
||||
assert "Invoice" in rendered
|
||||
assert "Receipt" in rendered
|
||||
|
||||
|
||||
def test_render_labels_handles_empty_vocabulary() -> None:
|
||||
assert "(none)" in render_labels([])
|
||||
|
||||
|
||||
# ── DocumentClassifierAgent (inline page text) ───────────────────────────────
|
||||
|
||||
|
||||
def _stub_model_answer(agent: DocumentClassifierAgent, labels: list[str]) -> AsyncMock:
|
||||
mock = AsyncMock(return_value=SimpleNamespace(output=_ClassifierOutput(labels=labels)))
|
||||
agent._agent.run = mock
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_classify_respects_request_supplied_vocabulary(runtime: AppRuntime) -> None:
|
||||
agent = DocumentClassifierAgent(runtime)
|
||||
run_mock = _stub_model_answer(agent, ["board minutes", "Invoice"])
|
||||
|
||||
result = await agent.classify(
|
||||
ClassifyDocumentRequest(
|
||||
file_name="minutes.pdf",
|
||||
pages=[PageText(page_number=1, text="Minutes of the board meeting")],
|
||||
labels=_opts("Board minutes", "Agenda"),
|
||||
)
|
||||
)
|
||||
|
||||
# "Invoice" is off this request's vocabulary even though the default knows it.
|
||||
assert result.labels == ["board-minutes"]
|
||||
assert run_mock.await_args is not None
|
||||
prompt = run_mock.await_args.args[0]
|
||||
assert "Board minutes" in prompt
|
||||
assert "Agenda" in prompt
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from stirling.api import app
|
||||
from stirling.api.dependencies import get_document_classifier_agent
|
||||
from stirling.contracts import (
|
||||
ClassifyDocumentRequest,
|
||||
ClassifyDocumentResponse,
|
||||
DocumentClassificationResponse,
|
||||
)
|
||||
|
||||
|
||||
class StubClassifierAgent:
|
||||
"""Stands in for DocumentClassifierAgent so route tests don't call a model."""
|
||||
|
||||
def __init__(self, response: ClassifyDocumentResponse) -> None:
|
||||
self._response = response
|
||||
|
||||
async def classify(self, _request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
|
||||
return self._response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def classification_client() -> Iterator[TestClient]:
|
||||
app.dependency_overrides[get_document_classifier_agent] = lambda: StubClassifierAgent(
|
||||
# The result is label ids (the model's name answers, mapped to ids).
|
||||
DocumentClassificationResponse(labels=["nda", "contract"])
|
||||
)
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_document_classifier_agent, None)
|
||||
|
||||
|
||||
_LABELS = [{"id": "nda", "name": "NDA"}, {"id": "contract", "name": "Contract"}]
|
||||
|
||||
|
||||
def test_classify_returns_assigned_labels(classification_client: TestClient) -> None:
|
||||
response = classification_client.post(
|
||||
"/api/v1/documents/classify",
|
||||
json={
|
||||
"fileName": "nda.pdf",
|
||||
"pages": [{"pageNumber": 1, "text": "Mutual NDA between A and B."}],
|
||||
"labels": _LABELS,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"labels": ["nda", "contract"]}
|
||||
|
||||
|
||||
def test_classify_accepts_allowed_labels_on_the_request(classification_client: TestClient) -> None:
|
||||
response = classification_client.post(
|
||||
"/api/v1/documents/classify",
|
||||
json={
|
||||
"fileName": "nda.pdf",
|
||||
"pages": [],
|
||||
"labels": [
|
||||
{"id": "contract", "name": "Contract"},
|
||||
{"id": "invoice", "name": "Invoice"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_classify_accepts_empty_pages(classification_client: TestClient) -> None:
|
||||
response = classification_client.post(
|
||||
"/api/v1/documents/classify",
|
||||
json={"fileName": "blank.pdf", "pages": [], "labels": _LABELS},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_classify_rejects_empty_file_name(classification_client: TestClient) -> None:
|
||||
response = classification_client.post(
|
||||
"/api/v1/documents/classify",
|
||||
json={"fileName": "", "pages": [], "labels": _LABELS},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_classify_rejects_missing_labels(classification_client: TestClient) -> None:
|
||||
# The backend always supplies the vocabulary; a request without one is invalid.
|
||||
response = classification_client.post(
|
||||
"/api/v1/documents/classify",
|
||||
json={"fileName": "nda.pdf", "pages": []},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -3691,7 +3691,9 @@ workbench = "Workbench"
|
||||
|
||||
[fileSidebar]
|
||||
addFiles = "Add files"
|
||||
addingFiles = "Adding files…"
|
||||
collapse = "Collapse sidebar"
|
||||
customizeGroups = "Customize groups"
|
||||
dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
expand = "Expand sidebar"
|
||||
@@ -3704,8 +3706,11 @@ noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
openFromComputer = "Open from computer"
|
||||
openSettings = "Open settings"
|
||||
other = "Other"
|
||||
recent = "Recent"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search files..."
|
||||
viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
@@ -3717,6 +3722,23 @@ updateOnServer = "Update on server"
|
||||
uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[fileSidebar.groupsModal]
|
||||
add = "Add"
|
||||
addLabel = "Add a label…"
|
||||
categoryIconAria = "Choose an icon for {{name}}"
|
||||
createCategory = "Create category"
|
||||
delete = "Delete category"
|
||||
done = "Done"
|
||||
hide = "Hide category"
|
||||
newCategory = "New category name…"
|
||||
removeLabel = "Remove {{name}}"
|
||||
rename = "Rename"
|
||||
reset = "Reset to defaults"
|
||||
search = "Search labels…"
|
||||
show = "Show category"
|
||||
subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”."
|
||||
title = "Sidebar categories"
|
||||
|
||||
[filesPage]
|
||||
addToWorkspace = "Add to workspace"
|
||||
addToWorkspaceCount = "Add {{count}} to workspace"
|
||||
@@ -3726,6 +3748,7 @@ backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSearch = "Clear search"
|
||||
clearSelection = "Clear selection"
|
||||
closeDetails = "Close details"
|
||||
@@ -3885,10 +3908,10 @@ uploadFilesFailedDetail = "Could not upload files: {{message}}"
|
||||
added = "Added"
|
||||
count = "Files"
|
||||
folder = "Folder"
|
||||
labels = "Labels"
|
||||
modified = "Modified"
|
||||
name = "Name"
|
||||
size = "Size"
|
||||
toolHistory = "Tool history"
|
||||
toolHistoryAtVersion = "Cumulative tool chain"
|
||||
totalSize = "Total size"
|
||||
type = "Type"
|
||||
@@ -5981,6 +6004,33 @@ print = "Enforcing before print"
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.labels]
|
||||
add = "Add"
|
||||
addPlaceholder = "Add a label…"
|
||||
customNote = "Customized for your team."
|
||||
defaultNote = "Using the built-in default, shared with your team."
|
||||
duplicate = "\"{{name}}\" already exists."
|
||||
edit = "Edit labels"
|
||||
empty = "No labels yet."
|
||||
export = "Export JSON"
|
||||
iconAria = "Choose an icon for {{name}}"
|
||||
import = "Import JSON"
|
||||
importError = "Couldn't import that file."
|
||||
managedNote = "Team labels are managed by your team leader."
|
||||
modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document."
|
||||
modalTitle = "Classification labels"
|
||||
pickIcon = "Use {{label}} icon"
|
||||
removeAria = "Remove {{name}}"
|
||||
resetToDefault = "Reset to default"
|
||||
saveForTeam = "Save for team"
|
||||
saving = "Saving…"
|
||||
sectionLabel = "Classification labels"
|
||||
startFromScratch = "Start from scratch"
|
||||
teamCount = "team labels"
|
||||
tooLong = "Labels can be at most {{max}} characters."
|
||||
ungrouped = "Ungrouped"
|
||||
view = "View labels"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
card = "Credit / debit cards"
|
||||
@@ -5992,17 +6042,30 @@ placeholder = "Select PII types"
|
||||
routing = "US routing numbers (ABA)"
|
||||
ssn = "Social Security numbers"
|
||||
|
||||
[policies.settings]
|
||||
noneExport = "No policies currently run on export."
|
||||
noneUpload = "No policies currently run on upload."
|
||||
onExport = "On export"
|
||||
onUpload = "On upload"
|
||||
reorderHandle = "Drag to reorder"
|
||||
runOrderDesc = "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder."
|
||||
title = "Policy settings"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
infoAriaLabel = "What is a policy?"
|
||||
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
loading = "Loading…"
|
||||
optionsAriaLabel = "Policy options"
|
||||
policySettings = "Policy settings"
|
||||
railAriaLabel = "{{label}} policy — {{status}}"
|
||||
railSuffixActive = " (Active)"
|
||||
railSuffixPaused = " (Paused)"
|
||||
retryFailed = "Retry failed policies ({{count}})"
|
||||
rowProgress = "{{completed}} of {{total}} files processed"
|
||||
setUp = "Set up"
|
||||
title = "Policies"
|
||||
upgradeToEnterprise = "Upgrade to enterprise"
|
||||
whatIsPolicy = "What is a policy?"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
|
||||
@@ -295,6 +295,9 @@ const FileEditorThumbnail = ({
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const policyEnforcing = policies.some((p) => p.enforcing);
|
||||
// Accent of the policy currently enforcing, so the overlay's icon/spinner match
|
||||
// that policy's badge instead of a fixed blue.
|
||||
const enforcingAccent = policies.find((p) => p.enforcing)?.accentColor;
|
||||
|
||||
const hoverActions = useMemo<HoverAction[]>(() => {
|
||||
const uploadLabel = isUploaded
|
||||
@@ -536,7 +539,11 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
|
||||
{/* Policy enforcement overlay — shown while any policy is in-flight */}
|
||||
<PolicyEnforcingOverlay enforcing={policyEnforcing} zIndex={2} />
|
||||
<PolicyEnforcingOverlay
|
||||
enforcing={policyEnforcing}
|
||||
zIndex={2}
|
||||
accentVar={enforcingAccent}
|
||||
/>
|
||||
|
||||
{/* Thumbnail image or loading state */}
|
||||
<DocumentThumbnail
|
||||
|
||||
@@ -22,10 +22,12 @@ import {
|
||||
downloadFileFromStorage,
|
||||
downloadMultipleFiles,
|
||||
} from "@app/utils/downloadUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { readStubClassificationLabels } from "@app/services/fileClassification";
|
||||
import { useLabelName } from "@app/data/labelDisplay";
|
||||
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
|
||||
import {
|
||||
VersionTimeline,
|
||||
DetailField,
|
||||
@@ -78,6 +80,14 @@ export function FileDetailsPanel({
|
||||
// Metadata (size/type/dates) is collapsed by default so the panel stays
|
||||
// short and the action buttons keep their pinned footer in view.
|
||||
const [fieldsOpen, setFieldsOpen] = useState(false);
|
||||
// Version journey is collapsed by default so the panel stays short.
|
||||
const [versionsOpen, setVersionsOpen] = useState(false);
|
||||
// Document classification read from PDF metadata, plus its (collapsed) section.
|
||||
// Stored as label ids; resolve to display names via the seam.
|
||||
const [classification, setClassification] = useState<string[] | null>(null);
|
||||
const [classificationOpen, setClassificationOpen] = useState(false);
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const labelName = useLabelName();
|
||||
// Version chain for the selected file; empty for v1 or multi-select.
|
||||
const [versionChain, setVersionChain] = useState<StirlingFileStub[]>([]);
|
||||
const singleFileForChain = files.length === 1 ? files[0] : null;
|
||||
@@ -103,6 +113,29 @@ export function FileDetailsPanel({
|
||||
};
|
||||
}, [singleFileForChain]);
|
||||
|
||||
// Show the file's classification labels: the stub's cached copy when present
|
||||
// (free — no byte load), else read the PDF metadata via the shared service.
|
||||
// Gated on classification being enabled (SaaS + AI): off-feature we never read
|
||||
// the metadata or show the section, so a PDF that happens to carry the
|
||||
// StirlingPDFClassification key never reveals the feature in a build without it.
|
||||
useEffect(() => {
|
||||
setClassification(null);
|
||||
if (!classificationEnabled) return;
|
||||
const stub = singleFileForChain;
|
||||
if (!stub) return;
|
||||
if (stub.classificationLabels && stub.classificationLabels.length > 0) {
|
||||
setClassification(stub.classificationLabels);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void readStubClassificationLabels(stub).then((labels) => {
|
||||
if (!cancelled && labels) setClassification(labels);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleFileForChain, classificationEnabled]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -241,17 +274,56 @@ export function FileDetailsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{single.toolHistory && single.toolHistory.length > 0 && (
|
||||
<div className="files-page-details-tool-history">
|
||||
<div className="files-page-details-tool-history-label">
|
||||
{t("filesPage.field.toolHistory", "Tool history")}
|
||||
</div>
|
||||
<ToolChain
|
||||
toolChain={single.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{classification && (
|
||||
<>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setClassificationOpen((o) => !o)}
|
||||
aria-expanded={classificationOpen}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
classificationOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{t("filesPage.classification", "Classification")}</span>
|
||||
</Button>
|
||||
{classificationOpen && (
|
||||
<div className="files-page-details-fieldlist">
|
||||
<div className="files-page-details-field">
|
||||
<span className="files-page-details-field-label">
|
||||
{t("filesPage.field.labels", "Labels")}
|
||||
</span>
|
||||
<span
|
||||
className="files-page-details-field-value"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{classification.map((label) => (
|
||||
<Badge
|
||||
key={label}
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
>
|
||||
{labelName(label)}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Version journey. Each tool run writes a new StirlingFile
|
||||
with the same `originalFileId` and an incremented
|
||||
@@ -275,12 +347,41 @@ export function FileDetailsPanel({
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
<>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setVersionsOpen((o) => !o)}
|
||||
aria-expanded={versionsOpen}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
versionsOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
"filesPage.viewVersionHistory",
|
||||
"Version journey ({{count}})",
|
||||
{ count: versionChain.length },
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{versionsOpen && (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
hideHeader
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface VersionTimelineProps {
|
||||
currentId: FileId;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
/** Version timeline with per-row tool deltas and collapse-when-long. */
|
||||
@@ -65,6 +66,7 @@ export function VersionTimeline({
|
||||
currentId,
|
||||
onAddToWorkspace,
|
||||
onRemove,
|
||||
hideHeader = false,
|
||||
}: VersionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
|
||||
@@ -122,15 +124,17 @@ export function VersionTimeline({
|
||||
|
||||
return (
|
||||
<div className="files-page-details-version-timeline">
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="files-page-details-version-timeline-list">
|
||||
{rows.map((row, idx) => {
|
||||
const isLast = idx === rows.length - 1;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { accentColor } from "@app/utils/accentColors";
|
||||
|
||||
export type FileDocVariant =
|
||||
| "pdf"
|
||||
| "spreadsheet"
|
||||
@@ -7,14 +9,15 @@ export type FileDocVariant =
|
||||
| "code"
|
||||
| "generic";
|
||||
|
||||
// Theme-adaptive accent per file type (shared palette; see utils/accentColors.ts).
|
||||
export const VARIANT_COLORS: Record<FileDocVariant, string> = {
|
||||
pdf: "#DC2626",
|
||||
spreadsheet: "#16a34a",
|
||||
doc: "#2563eb",
|
||||
image: "#7c3aed",
|
||||
archive: "#ea580c",
|
||||
code: "#0891b2",
|
||||
generic: "#71717a",
|
||||
pdf: accentColor("red"),
|
||||
spreadsheet: accentColor("green"),
|
||||
doc: accentColor("blue"),
|
||||
image: accentColor("violet"),
|
||||
archive: accentColor("orange"),
|
||||
code: accentColor("cyan"),
|
||||
generic: accentColor("gray"),
|
||||
};
|
||||
|
||||
export function FileDocIcon({
|
||||
@@ -44,7 +47,7 @@ export function FileDocIcon({
|
||||
const row1Y = 11,
|
||||
row2Y = 14;
|
||||
interior = (
|
||||
<g opacity="0.7" stroke={color} strokeWidth="1" fill="none">
|
||||
<g opacity="0.7" stroke="currentColor" strokeWidth="1" fill="none">
|
||||
<rect x={gx1} y={gy1} width={gx2 - gx1} height={gy2 - gy1} rx="0.5" />
|
||||
<line x1={colX} y1={gy1} x2={colX} y2={gy2} />
|
||||
<line x1={gx1} y1={row1Y} x2={gx2} y2={row1Y} />
|
||||
@@ -56,7 +59,7 @@ export function FileDocIcon({
|
||||
<>
|
||||
<polyline
|
||||
points="2.5,16 5.5,11.5 8,14 10,12 13.5,16"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
@@ -67,7 +70,7 @@ export function FileDocIcon({
|
||||
cx="11"
|
||||
cy="9.5"
|
||||
r="1.3"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
opacity="0.6"
|
||||
fill="none"
|
||||
@@ -79,7 +82,7 @@ export function FileDocIcon({
|
||||
<>
|
||||
<polyline
|
||||
points="6,9.5 3.5,12.5 6,15.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
@@ -88,7 +91,7 @@ export function FileDocIcon({
|
||||
/>
|
||||
<polyline
|
||||
points="10,9.5 12.5,12.5 10,15.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
opacity="0.7"
|
||||
fill="none"
|
||||
@@ -105,7 +108,7 @@ export function FileDocIcon({
|
||||
y1="9.5"
|
||||
x2="11"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
@@ -116,7 +119,7 @@ export function FileDocIcon({
|
||||
y1="12"
|
||||
x2="11"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
@@ -127,7 +130,7 @@ export function FileDocIcon({
|
||||
y1="14.5"
|
||||
x2="11"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeDasharray="2,1.5"
|
||||
@@ -144,7 +147,7 @@ export function FileDocIcon({
|
||||
y1="9.5"
|
||||
x2="12.5"
|
||||
y2="9.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
@@ -154,7 +157,7 @@ export function FileDocIcon({
|
||||
y1="12"
|
||||
x2="12.5"
|
||||
y2="12"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
@@ -164,7 +167,7 @@ export function FileDocIcon({
|
||||
y1="14.5"
|
||||
x2="8.5"
|
||||
y2="14.5"
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.6"
|
||||
strokeLinecap="round"
|
||||
@@ -176,7 +179,9 @@ export function FileDocIcon({
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
style={{ width: 16, height: 20, ...style }}
|
||||
// The accent flows through `color` (var() resolves in CSS, not in SVG
|
||||
// presentation attributes) and the shapes pick it up via currentColor.
|
||||
style={{ width: 16, height: 20, color, ...style }}
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
@@ -184,7 +189,7 @@ export function FileDocIcon({
|
||||
{/* Document outline with top-right page fold */}
|
||||
<path
|
||||
d={`M 1.5,1 H ${foldX} L 15,${foldY} V 18.5 Q 15,19 14.5,19 H 1.5 Q 1,19 1,18.5 V 1.5 Q 1,1 1.5,1 Z`}
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
@@ -192,9 +197,9 @@ export function FileDocIcon({
|
||||
{/* Fold corner triangle */}
|
||||
<path
|
||||
d={`M ${foldX},1 L ${foldX},${foldY} L 15,${foldY} Z`}
|
||||
stroke={color}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
fill={color}
|
||||
fill="currentColor"
|
||||
fillOpacity="0.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
@@ -305,6 +305,128 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/* --- Grouped list (SaaS: Recent + per-category groups). Inert when the
|
||||
grouping seam returns no groups (OSS/desktop render the flat list). --- */
|
||||
.file-sidebar-group + .file-sidebar-group {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-sidebar-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 5px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-default, var(--color-text-1));
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.file-sidebar-group-header:hover {
|
||||
background: var(--hover-bg, var(--color-bg-hover));
|
||||
}
|
||||
|
||||
.file-sidebar-group-icon {
|
||||
flex: none;
|
||||
color: var(--text-muted, var(--color-text-3));
|
||||
}
|
||||
|
||||
.file-sidebar-group-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-sidebar-group-count {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, var(--color-text-3));
|
||||
}
|
||||
|
||||
/* Full-width, flat rows: no inset, no radius, so the hover/selected highlight
|
||||
bleeds edge to edge. The file content is indented under the group header via
|
||||
the row's own left padding (the background still spans the full width). */
|
||||
.file-sidebar-group-items {
|
||||
padding-left: 0;
|
||||
}
|
||||
.file-sidebar-group-items .file-sidebar-file-item {
|
||||
padding: 5px 10px 5px 1.4rem;
|
||||
gap: 8px;
|
||||
border-radius: 0;
|
||||
}
|
||||
/* Hover reads neutral (not the default blue tint); selected stays blue. */
|
||||
.file-sidebar-group-items .file-sidebar-file-item:hover:not(.selected) {
|
||||
background-color: var(--color-bg-hover, rgba(120, 120, 120, 0.1));
|
||||
}
|
||||
/* A plain circle where the selected check sits — on hover, and blue when selected. */
|
||||
.file-sidebar-group-items .file-sidebar-file-checkbox-hover,
|
||||
.file-sidebar-group-items .file-sidebar-file-check {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Smaller, tighter icon column in grouped rows (colour left as-is for now). */
|
||||
.file-sidebar-group-items .file-sidebar-file-icon-wrapper,
|
||||
.file-sidebar-group-items .file-sidebar-file-check,
|
||||
.file-sidebar-group-items .file-sidebar-file-checkbox-hover {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* The processing spinner keeps the file icons' visual size (16px reads too
|
||||
timid for a motion cue) — it overflows the icon column slightly, centered
|
||||
via negative margin so the row layout doesn't shift. */
|
||||
.file-sidebar-group-items .file-sidebar-file-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
/* Row actions take NO width at rest, so the name uses the full row before
|
||||
truncating; they appear on hover (or while viewed / the kebab menu is open),
|
||||
at which point the name truncates to make room. */
|
||||
.file-sidebar-group-items .file-sidebar-eye-btn,
|
||||
.file-sidebar-group-items .file-sidebar-kebab-btn {
|
||||
display: none;
|
||||
}
|
||||
.file-sidebar-group-items .file-sidebar-file-item:hover .file-sidebar-eye-btn,
|
||||
.file-sidebar-group-items .file-sidebar-file-item:hover .file-sidebar-kebab-btn,
|
||||
.file-sidebar-group-items .file-sidebar-kebab-btn[aria-expanded="true"],
|
||||
.file-sidebar-group-items .file-sidebar-file-item.viewed .file-sidebar-eye-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* "View all N files →" footer under the groups. */
|
||||
.file-sidebar-view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-blue, #2563eb);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
overflow: visible;
|
||||
}
|
||||
.file-sidebar-view-all::before {
|
||||
display: none;
|
||||
}
|
||||
.file-sidebar-view-all:hover {
|
||||
background: var(--color-bg-hover, rgba(120, 120, 120, 0.1));
|
||||
}
|
||||
|
||||
.file-sidebar-file-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
@@ -328,6 +450,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 6px 14px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
@@ -348,6 +471,35 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Slim "Adding files… X/Y" progress row shown during a bulk drop's pre-scan,
|
||||
so a 200-file folder never looks frozen between drop and rows appearing. */
|
||||
.file-sidebar-bulk-add {
|
||||
padding: 4px 14px 8px;
|
||||
}
|
||||
.file-sidebar-bulk-add-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.file-sidebar-bulk-add-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.file-sidebar-bulk-add-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-blue, #3b82f6) 15%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-sidebar-bulk-add-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-blue, #3b82f6);
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
.file-sidebar-section-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
} from "react";
|
||||
import { Loader, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
|
||||
@@ -42,6 +43,15 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
|
||||
import { useLabelName } from "@app/data/labelDisplay";
|
||||
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
FileSidebarGroupControls,
|
||||
useFileSidebarGroups,
|
||||
} from "@app/components/shared/fileSidebarGrouping";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
|
||||
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
|
||||
@@ -51,6 +61,7 @@ import {
|
||||
type DeleteScope,
|
||||
} from "@app/services/serverStorageDelete";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
@@ -69,6 +80,14 @@ const EXPANDED_WIDTH = "16.25rem"; // ~260px
|
||||
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||
const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder";
|
||||
|
||||
// Stable empty props for rows without folders, so the memoized FileItem
|
||||
// isn't re-rendered by a fresh `?? []` identity on every list render.
|
||||
const NO_FOLDERS: never[] = [];
|
||||
|
||||
/** Only surface the "Adding files…" progress row for drops big enough that the
|
||||
* pre-dispatch scan is user-visible; small adds finish before it would paint. */
|
||||
const BULK_ADD_INDICATOR_MIN = 8;
|
||||
|
||||
export interface FileSidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
@@ -94,6 +113,38 @@ export interface FileSidebarProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim "Adding files… X/Y" indicator during a bulk drop's pre-dispatch scan, so
|
||||
* a 200-file folder never looks frozen between drop and rows appearing. A
|
||||
* SEPARATE component so the per-file progress emissions re-render only this row
|
||||
* — not the whole sidebar (and its file list) hundreds of times per drop.
|
||||
*/
|
||||
function BulkAddProgressRow() {
|
||||
const { t } = useTranslation();
|
||||
const bulkAdd = useBulkAddProgress();
|
||||
if (bulkAdd.total < BULK_ADD_INDICATOR_MIN || bulkAdd.done >= bulkAdd.total) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="file-sidebar-bulk-add" role="status" aria-live="polite">
|
||||
<div className="file-sidebar-bulk-add-label">
|
||||
<span>{t("fileSidebar.addingFiles", "Adding files…")}</span>
|
||||
<span className="file-sidebar-bulk-add-count">
|
||||
{bulkAdd.done}/{bulkAdd.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="file-sidebar-bulk-add-track">
|
||||
<div
|
||||
className="file-sidebar-bulk-add-bar"
|
||||
style={{
|
||||
width: `${Math.round((bulkAdd.done / bulkAdd.total) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function FileSidebar(
|
||||
{
|
||||
@@ -110,6 +161,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
// Resolves a file's stored classification label id to its display name.
|
||||
const labelName = useLabelName();
|
||||
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
|
||||
// even if a stub carries labels from an imported PDF; keeps the row plain.
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const [searchActive, setSearchActive] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -261,16 +317,41 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
|
||||
const superseded = new Set(
|
||||
allStubs.map((s) => s.parentFileId as string | undefined),
|
||||
);
|
||||
const currentStubs = allStubs.filter(
|
||||
(s) => !superseded.has(s.id as string),
|
||||
);
|
||||
setAllFileStubs(
|
||||
allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)),
|
||||
currentStubs.sort(
|
||||
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
|
||||
),
|
||||
);
|
||||
setStubsLoaded(true);
|
||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes —
|
||||
// COALESCED. refreshStubs is a full IDB metadata scan, and it's re-created on
|
||||
// every workspace change: during a big folder drop (or a policy wave) that's
|
||||
// hundreds of triggers (per-file thumbnail hydrations, versioned deliveries),
|
||||
// which uncoalesced means O(files²) IDB reads and a re-render storm. A short
|
||||
// trailing throttle turns a burst into one scan per window; the first run
|
||||
// fires immediately so mount/load isn't delayed.
|
||||
const indexedDBRevision = useIndexedDBRevision();
|
||||
const lastRefreshAt = useRef(0);
|
||||
useEffect(() => {
|
||||
refreshStubs();
|
||||
const REFRESH_COALESCE_MS = 300;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastRefreshAt.current + REFRESH_COALESCE_MS - Date.now(),
|
||||
);
|
||||
const timer = window.setTimeout(() => {
|
||||
lastRefreshAt.current = Date.now();
|
||||
void refreshStubs();
|
||||
}, wait);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [refreshStubs, indexedDBRevision]);
|
||||
|
||||
// Kebab delete: local-only files go immediately (cheap, re-addable). When
|
||||
@@ -356,11 +437,39 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}
|
||||
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
|
||||
|
||||
const filteredFileStubs = searchQuery.trim()
|
||||
? allFileStubs.filter((stub) =>
|
||||
stub.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: allFileStubs;
|
||||
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
|
||||
// stable array identity — avoids re-running the grouping memo + backfill effect.
|
||||
const filteredFileStubs = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return q
|
||||
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
|
||||
: allFileStubs;
|
||||
}, [allFileStubs, searchQuery]);
|
||||
|
||||
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
|
||||
const fileGroups = useFileSidebarGroups(filteredFileStubs);
|
||||
// Workbench membership as a Set for O(1) per-row lookups (see renderFileRow).
|
||||
const workbenchIds = useMemo(
|
||||
() => new Set(state.files.ids.map((id) => id as string)),
|
||||
[state.files.ids],
|
||||
);
|
||||
// How many rendered stubs share each lineage — >1 means split siblings, which
|
||||
// must key by their unique leaf id rather than the shared lineage (see renderFileRow).
|
||||
const lineageCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of filteredFileStubs) {
|
||||
const k = (s.originalFileId ?? s.id) as string;
|
||||
counts.set(k, (counts.get(k) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [filteredFileStubs]);
|
||||
// Per-group expand/collapse, falling back to each group's default until toggled.
|
||||
const [groupOpen, setGroupOpen] = useState<Record<string, boolean>>({});
|
||||
const setGroupOpenState = useCallback(
|
||||
(id: string, open: boolean) =>
|
||||
setGroupOpen((prev) => ({ ...prev, [id]: open })),
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle search activation
|
||||
const handleSearchClick = useCallback(() => {
|
||||
@@ -617,6 +726,87 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
|
||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||
|
||||
// Render one file row (shared by the flat list and the grouped SaaS layout).
|
||||
const renderFileRow = (stub: StirlingFileStub) => {
|
||||
// O(1) membership instead of a per-row linear scan of the workbench ids.
|
||||
const isInWorkbench = workbenchIds.has(stub.id as string);
|
||||
const workbenchFileId = isInWorkbench ? (stub.id as FileId) : undefined;
|
||||
const isSelected = isWatchedFoldersActive
|
||||
? activeWatchedFolderId != null &&
|
||||
(folderMembership
|
||||
.get(stub.id as string)
|
||||
?.includes(activeWatchedFolderId) ??
|
||||
false)
|
||||
: isInWorkbench;
|
||||
const showFolderDots =
|
||||
WATCHED_FOLDERS_ENABLED &&
|
||||
isWatchedFoldersActive &&
|
||||
activeWatchedFolderId === null;
|
||||
const memberFolders = showFolderDots
|
||||
? (folderMembership.get(stub.id as string) ?? [])
|
||||
.map((fid) => folderById.get(fid))
|
||||
.filter((f): f is NonNullable<typeof f> => !!f)
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
accentColor: f.accentColor,
|
||||
}))
|
||||
: NO_FOLDERS;
|
||||
const isViewedInViewer = !!(
|
||||
viewedWorkbenchId && viewedWorkbenchId === (stub.id as string)
|
||||
);
|
||||
const isActive = isViewedInViewer;
|
||||
const isEncryptedFile = stub.processedFile?.isEncrypted === true;
|
||||
const thumbnailUrl = isEncryptedFile
|
||||
? undefined
|
||||
: (workbenchFileId
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
const fileOrigin = getFileOrigin(stub);
|
||||
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
|
||||
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
|
||||
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
|
||||
// lineage is present more than once.
|
||||
const lineageKey = (stub.originalFileId ?? stub.id) as string;
|
||||
const rowKey =
|
||||
(lineageCounts.get(lineageKey) ?? 0) > 1
|
||||
? (stub.id as string)
|
||||
: lineageKey;
|
||||
return (
|
||||
<FileItem
|
||||
key={rowKey}
|
||||
fileId={stub.id}
|
||||
name={stub.name}
|
||||
size={stub.size}
|
||||
lastModified={stub.lastModified}
|
||||
isSelected={isSelected}
|
||||
isActive={isActive}
|
||||
isViewedInViewer={isViewedInViewer}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
draggable={isWatchedFoldersActive}
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
onFolderClick={openWatchedFolder}
|
||||
policies={policyFileBadges.get(stub.id as string) ?? []}
|
||||
onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete}
|
||||
onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud}
|
||||
canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"}
|
||||
isUploadedToCloud={fileOrigin === "cloud"}
|
||||
onVersionHistory={
|
||||
isWatchedFoldersActive ? undefined : handleVersionHistory
|
||||
}
|
||||
hasVersionHistory={(stub.versionNumber ?? 1) > 1}
|
||||
primaryLabel={
|
||||
classificationEnabled && stub.classificationLabels?.[0]
|
||||
? labelName(stub.classificationLabels[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -974,6 +1164,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.files", "Files")}
|
||||
</span>
|
||||
<FileSidebarGroupControls stubs={filteredFileStubs} />
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
@@ -1001,107 +1192,93 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
<BulkAddProgressRow />
|
||||
|
||||
{!stubsLoaded ? (
|
||||
<div className="file-sidebar-loading">
|
||||
<Loader size="sm" color="var(--text-muted)" />
|
||||
</div>
|
||||
) : filteredFileStubs.length > 0 ? (
|
||||
<div className="file-sidebar-file-list">
|
||||
{filteredFileStubs.map((stub) => {
|
||||
const workbenchFileId = state.files.ids.find(
|
||||
(id) => (id as string) === (stub.id as string),
|
||||
);
|
||||
const isInWorkbench = !!workbenchFileId;
|
||||
// On Watched Folders, the tick means "this file is already in
|
||||
// the open folder"; on the folder home (no folder open) a
|
||||
// click is a no-op, so show no tick at all.
|
||||
const isSelected = isWatchedFoldersActive
|
||||
? activeWatchedFolderId != null &&
|
||||
(folderMembership
|
||||
.get(stub.id as string)
|
||||
?.includes(activeWatchedFolderId) ??
|
||||
false)
|
||||
: isInWorkbench;
|
||||
// Membership dots only on the Watched Folders home (the folder
|
||||
// grid, no folder open). Inside a specific folder the tick
|
||||
// already shows "in this folder"; in other views they'd just
|
||||
// be noise.
|
||||
const showFolderDots =
|
||||
WATCHED_FOLDERS_ENABLED &&
|
||||
isWatchedFoldersActive &&
|
||||
activeWatchedFolderId === null;
|
||||
const memberFolders = showFolderDots
|
||||
? (folderMembership.get(stub.id as string) ?? [])
|
||||
.map((fid) => folderById.get(fid))
|
||||
.filter((f): f is NonNullable<typeof f> => !!f)
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
accentColor: f.accentColor,
|
||||
}))
|
||||
: [];
|
||||
// Both active and viewed-in-viewer are ID-based - never index-based.
|
||||
const isViewedInViewer = !!(
|
||||
viewedWorkbenchId &&
|
||||
viewedWorkbenchId === (stub.id as string)
|
||||
);
|
||||
const isActive = isViewedInViewer;
|
||||
// In-memory thumbnail may be fresher than the IndexedDB stub.
|
||||
// Encrypted files never get a raster thumbnail - use undefined
|
||||
// so the sidebar icon is shown instead of a stale canvas thumbnail.
|
||||
const isEncryptedFile =
|
||||
stub.processedFile?.isEncrypted === true;
|
||||
const thumbnailUrl = isEncryptedFile
|
||||
? undefined
|
||||
: (workbenchFileId
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
// local | cloud | shared-with-me - drives the cloud badge
|
||||
// and the Upload-vs-Update menu label.
|
||||
const fileOrigin = getFileOrigin(stub);
|
||||
return (
|
||||
<FileItem
|
||||
key={stub.id}
|
||||
fileId={stub.id}
|
||||
name={stub.name}
|
||||
size={stub.size}
|
||||
lastModified={stub.lastModified}
|
||||
isSelected={isSelected}
|
||||
isActive={isActive}
|
||||
isViewedInViewer={isViewedInViewer}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
draggable={isWatchedFoldersActive}
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
onFolderClick={openWatchedFolder}
|
||||
policies={
|
||||
policyFileBadges.get(stub.id as string) ?? []
|
||||
{fileGroups ? (
|
||||
<>
|
||||
{fileGroups.map((group) => {
|
||||
const isOpen =
|
||||
groupOpen[group.id] ?? group.defaultExpanded;
|
||||
return (
|
||||
<div className="file-sidebar-group" key={group.id}>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-group-header"
|
||||
onClick={() =>
|
||||
setGroupOpenState(group.id, !isOpen)
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
) : (
|
||||
<KeyboardArrowRightIcon
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
)}
|
||||
{group.icon && (
|
||||
<LocalIcon
|
||||
icon={group.icon}
|
||||
width="1.05rem"
|
||||
className="file-sidebar-group-icon"
|
||||
style={
|
||||
group.color
|
||||
? { color: group.color }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="file-sidebar-group-count">
|
||||
{group.stubs.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="file-sidebar-group-label">
|
||||
{group.label}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="file-sidebar-group-items">
|
||||
{isOpen && group.stubs.map(renderFileRow)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="file-sidebar-view-all"
|
||||
onClick={() => navigate("/files")}
|
||||
rightSection={
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onDelete={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleSidebarDelete
|
||||
}
|
||||
onSaveToCloud={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleSaveToCloud
|
||||
}
|
||||
canSaveToCloud={
|
||||
storageEnabled && fileOrigin !== "shared-with-me"
|
||||
}
|
||||
isUploadedToCloud={fileOrigin === "cloud"}
|
||||
onVersionHistory={
|
||||
isWatchedFoldersActive
|
||||
? undefined
|
||||
: handleVersionHistory
|
||||
}
|
||||
hasVersionHistory={(stub.versionNumber ?? 1) > 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
>
|
||||
{t(
|
||||
"fileSidebar.viewAll",
|
||||
"View all {{count}} files",
|
||||
{
|
||||
count: filteredFileStubs.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
filteredFileStubs.map(renderFileRow)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!searchActive && (
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
/* Skip layout/paint for offscreen rows — with hundreds of files the browser
|
||||
only renders the viewport's worth. The intrinsic-size hint keeps scrollbar
|
||||
geometry stable for unrendered rows. */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 46px;
|
||||
}
|
||||
|
||||
.file-sidebar-file-item:hover:not(.selected) {
|
||||
@@ -98,6 +103,47 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Processing state: a spinner in the running policy's colour (set inline via
|
||||
`color`) with that policy's badge glyph held still in the centre. Fills the
|
||||
20px icon slot, replacing the file icon while a policy runs on the file. */
|
||||
.file-sidebar-file-spinner {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-sidebar-file-spinner-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-top-color: currentColor;
|
||||
animation: file-sidebar-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.file-sidebar-file-spinner-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@keyframes file-sidebar-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.file-sidebar-file-spinner-ring {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
/* File info */
|
||||
.file-sidebar-file-info {
|
||||
flex: 1;
|
||||
|
||||
@@ -156,6 +156,9 @@ export interface FileItemProps {
|
||||
onFolderClick?: (folderId: string) => void;
|
||||
/** Policies that have run on this file — rendered as small shield badges. */
|
||||
policies?: FileItemPolicyRef[];
|
||||
/** The file's primary classification label (display name), shown in the meta
|
||||
* row in place of the file type when present. */
|
||||
primaryLabel?: string;
|
||||
/** Delete (local only) from the kebab menu. Omit to hide the menu's delete. */
|
||||
onDelete?: (fileId: FileId) => void;
|
||||
/** Save to cloud from the kebab menu. */
|
||||
@@ -188,6 +191,7 @@ export function FileItem({
|
||||
folders = [],
|
||||
onFolderClick,
|
||||
policies = [],
|
||||
primaryLabel,
|
||||
onDelete,
|
||||
onSaveToCloud,
|
||||
canSaveToCloud = false,
|
||||
@@ -282,9 +286,19 @@ export function FileItem({
|
||||
</span>
|
||||
<span className="file-sidebar-file-meta-row">
|
||||
<span className="file-sidebar-file-meta">
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
{primaryLabel ? (
|
||||
<>
|
||||
{primaryLabel}
|
||||
{primaryLabel && dateLabel ? " • " : ""}
|
||||
{dateLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
{typeLabel}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{isUploadedToCloud && (
|
||||
<Tooltip
|
||||
|
||||
@@ -2,6 +2,8 @@ export function PolicyEnforcingOverlay(_props: {
|
||||
enforcing: boolean;
|
||||
progress?: number;
|
||||
zIndex?: number;
|
||||
/** CSS colour var for the enforcing policy's accent; tints the icon/spinner. */
|
||||
accentVar?: string;
|
||||
}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Extension seam for how the Files sidebar groups its list; core returns null (one flat, recency-sorted list) and a higher layer overrides this to group by classification. The shared sidebar just renders whatever groups it's handed.
|
||||
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
export interface FileSidebarGroup {
|
||||
/** Stable key for collapse state + React keys. */
|
||||
id: string;
|
||||
/** Group header text. */
|
||||
label: string;
|
||||
/** Optional Material Symbols icon key (rendered via LocalIcon). */
|
||||
icon?: string;
|
||||
/** Optional CSS colour for the group's icon (e.g. a per-category accent). */
|
||||
color?: string;
|
||||
/** Files in this group, in display order. */
|
||||
stubs: StirlingFileStub[];
|
||||
/** Whether the group starts expanded (the sidebar owns the live toggle state). */
|
||||
defaultExpanded: boolean;
|
||||
}
|
||||
|
||||
// Groups to render, or null for a flat list (core = flat). A hook so overrides may read app state.
|
||||
export function useFileSidebarGroups(
|
||||
_stubs: StirlingFileStub[],
|
||||
): FileSidebarGroup[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Header control for customizing the grouping; core has none, an override renders a group picker.
|
||||
export function FileSidebarGroupControls(_props: {
|
||||
stubs: StirlingFileStub[];
|
||||
}) {
|
||||
return null;
|
||||
}
|
||||
@@ -268,10 +268,13 @@ function FileContextInner({
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
if (stirlingFiles.length > 0) {
|
||||
indexedDB?.bumpRevision?.();
|
||||
}
|
||||
|
||||
return stirlingFiles;
|
||||
},
|
||||
[enablePersistence, requestConfirmation],
|
||||
[enablePersistence, requestConfirmation, indexedDB],
|
||||
);
|
||||
|
||||
const addFilesWithOptions = useCallback(
|
||||
@@ -307,9 +310,13 @@ function FileContextInner({
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
|
||||
if (stirlingFiles.length > 0) {
|
||||
indexedDB?.bumpRevision?.();
|
||||
}
|
||||
|
||||
return stirlingFiles;
|
||||
},
|
||||
[enablePersistence],
|
||||
[enablePersistence, indexedDB],
|
||||
);
|
||||
|
||||
const addStirlingFileStubsAction = useCallback(
|
||||
@@ -346,6 +353,7 @@ function FileContextInner({
|
||||
inputFileIds: FileId[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
options?: { silent?: boolean },
|
||||
): Promise<FileId[]> => {
|
||||
return consumeFiles(
|
||||
inputFileIds,
|
||||
@@ -353,6 +361,7 @@ function FileContextInner({
|
||||
outputStirlingFileStubs,
|
||||
filesRef,
|
||||
dispatch,
|
||||
options,
|
||||
);
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -111,3 +111,114 @@ describe("fileContextReducer — derivedFromTool provenance", () => {
|
||||
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", () => {
|
||||
it("replaces the input in its existing slot without moving the output to the front", () => {
|
||||
const start = stateWith([stub("a"), stub("b"), stub("c")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("b2")],
|
||||
silent: true,
|
||||
},
|
||||
});
|
||||
// b2 takes b's middle slot — not the front (which is what non-silent does).
|
||||
expect(next.files.ids).toEqual(["a", "b2", "c"]);
|
||||
});
|
||||
|
||||
it("does not auto-select the output (nothing was selected)", () => {
|
||||
const start = stateWith([stub("a")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("a2")],
|
||||
silent: true,
|
||||
},
|
||||
});
|
||||
expect(next.ui.selectedFileIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves selection: a selected input's replacement stays selected in place", () => {
|
||||
const start = {
|
||||
...stateWith([stub("a"), stub("b")]),
|
||||
ui: { ...initialFileContextState.ui, selectedFileIds: ["b" as FileId] },
|
||||
};
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("b2")],
|
||||
silent: true,
|
||||
},
|
||||
});
|
||||
expect(next.ui.selectedFileIds).toEqual(["b2"]);
|
||||
});
|
||||
|
||||
it("is a no-op on the workbench when the input was already closed", () => {
|
||||
// The file was removed from the workspace while its run was in flight; the
|
||||
// finished run must NOT re-add it (it's already persisted to storage).
|
||||
const start = stateWith([stub("other")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["gone" as FileId],
|
||||
outputStirlingFileStubs: [stub("gone2")],
|
||||
silent: true,
|
||||
},
|
||||
});
|
||||
expect(next.files.ids).toEqual(["other"]);
|
||||
expect(next.files.byId["gone2" as FileId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries classificationLabels forward from input to output", () => {
|
||||
// A labelled file "a" is edited by a tool → "b" (which carries no labels of
|
||||
// its own). The output must inherit them so it stays in its label groups
|
||||
// instead of dropping to "Other" and waiting on a PDF re-read.
|
||||
const start = stateWith([
|
||||
stub("a", { classificationLabels: ["Invoice", "Receipt"] }),
|
||||
]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [stub("b")],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].classificationLabels).toEqual([
|
||||
"Invoice",
|
||||
"Receipt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("an output's own classificationLabels win over the input's", () => {
|
||||
// A re-classify produces an output that already carries (fresher) labels.
|
||||
const start = stateWith([stub("a", { classificationLabels: ["Invoice"] })]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["a" as FileId],
|
||||
outputStirlingFileStubs: [
|
||||
stub("b", { classificationLabels: ["Contract"] }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(next.files.byId["b" as FileId].classificationLabels).toEqual([
|
||||
"Contract",
|
||||
]);
|
||||
});
|
||||
|
||||
it("non-silent CONSUME_FILES still moves the output to the front (unchanged)", () => {
|
||||
const start = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(start, {
|
||||
type: "CONSUME_FILES",
|
||||
payload: {
|
||||
inputFileIds: ["b" as FileId],
|
||||
outputStirlingFileStubs: [stub("b2")],
|
||||
},
|
||||
});
|
||||
expect(next.files.ids).toEqual(["b2", "a"]);
|
||||
expect(next.ui.selectedFileIds).toEqual(["b2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,82 @@ function processFileSwap(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place variant of {@link processFileSwap} for background enforcement: the
|
||||
* outputs take the FIRST removed input's grid position (rather than jumping to
|
||||
* the front), and the outputs are NOT auto-selected — they only inherit the
|
||||
* input's selection state, so a file that was selected stays selected in place
|
||||
* and one that wasn't stays unselected. This keeps a finished policy run from
|
||||
* yanking the file to the top of the list or pulling it into view.
|
||||
*/
|
||||
function processFileSwapInPlace(
|
||||
state: FileContextState,
|
||||
filesToRemove: FileId[],
|
||||
filesToAdd: StirlingFileStub[],
|
||||
): FileContextState {
|
||||
const unpinnedRemoveIds = filesToRemove.filter(
|
||||
(id) => !state.pinnedFiles.has(id),
|
||||
);
|
||||
const removeSet = new Set(unpinnedRemoveIds);
|
||||
|
||||
// If none of the inputs are in the workspace anymore (e.g. the user closed the
|
||||
// file after its background run started), don't re-add the outputs — they're
|
||||
// already persisted to storage. Leaving the workbench untouched is what stops a
|
||||
// finished run from re-opening a file the user has since closed.
|
||||
const inputPresent = state.files.ids.some((id) => removeSet.has(id));
|
||||
if (!inputPresent) return state;
|
||||
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedRemoveIds.forEach((id) => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
const addedIds: FileId[] = [];
|
||||
filesToAdd.forEach((record) => {
|
||||
if (!newById[record.id]) {
|
||||
addedIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Insert the outputs where the first removed input sat. Because that index is
|
||||
// the FIRST removed id, every id before it survives — so it's also the correct
|
||||
// insertion index into the inputs-removed list.
|
||||
const firstIdx = state.files.ids.findIndex((id) => removeSet.has(id));
|
||||
const withoutInputs = state.files.ids.filter((id) => !removeSet.has(id));
|
||||
const newIds =
|
||||
firstIdx === -1
|
||||
? [...withoutInputs, ...addedIds]
|
||||
: [
|
||||
...withoutInputs.slice(0, firstIdx),
|
||||
...addedIds,
|
||||
...withoutInputs.slice(firstIdx),
|
||||
];
|
||||
|
||||
// Outputs inherit the input's selection state (no auto-select).
|
||||
const inputWasSelected = filesToRemove.some((id) =>
|
||||
state.ui.selectedFileIds.includes(id),
|
||||
);
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(
|
||||
(id) => !removeSet.has(id),
|
||||
);
|
||||
const newSelectedFileIds = inputWasSelected
|
||||
? [...validSelectedFileIds, ...addedIds]
|
||||
: validSelectedFileIds;
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: newIds,
|
||||
byId: newById,
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: newSelectedFileIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Pure reducer function
|
||||
export function fileContextReducer(
|
||||
state: FileContextState,
|
||||
@@ -286,13 +362,13 @@ export function fileContextReducer(
|
||||
}
|
||||
|
||||
case "CONSUME_FILES": {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
const { inputFileIds, outputStirlingFileStubs, silent } = action.payload;
|
||||
|
||||
// Transitive provenance: the outputs derive from these inputs AND from
|
||||
// whatever those inputs themselves derived from. Accumulating the closure
|
||||
// (rather than just the immediate inputs) means a policy badge still
|
||||
// resolves after an intermediate edit has been consumed and removed —
|
||||
// e.g. redact → edit → split still tags each split part. Captured before
|
||||
// e.g. redact → edit → split still marks each split part. Captured before
|
||||
// the inputs are swapped out below.
|
||||
const sourceFileIds = Array.from(
|
||||
new Set(
|
||||
@@ -303,18 +379,35 @@ export function fileContextReducer(
|
||||
),
|
||||
);
|
||||
|
||||
// Carry the document's classification labels forward across the edit: any
|
||||
// tool that versions/derives a classified file keeps it in its label
|
||||
// groups instead of dropping to "Other" and waiting on a PDF re-read.
|
||||
// Inherited from the first input that has any; an output that already
|
||||
// carries its own (e.g. a fresh classify result) keeps them.
|
||||
const inheritedLabels = inputFileIds
|
||||
.map((id) => state.files.byId[id]?.classificationLabels)
|
||||
.find((labels) => labels && labels.length > 0);
|
||||
|
||||
// Mark every consume output as tool-produced (the single chokepoint for
|
||||
// both versioned edits and independent artifacts like convert/split/merge)
|
||||
// and stamp its provenance. Tag here, not in processFileSwap, so
|
||||
// UNDO_CONSUME (which restores the original inputs through the same helper)
|
||||
// doesn't mislabel real uploads.
|
||||
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||
const provenancedOutputs = outputStirlingFileStubs.map((stub) => ({
|
||||
...stub,
|
||||
derivedFromTool: true,
|
||||
sourceFileIds,
|
||||
classificationLabels: stub.classificationLabels ?? inheritedLabels,
|
||||
}));
|
||||
|
||||
return processFileSwap(state, inputFileIds, taggedOutputs);
|
||||
// Silent (background enforcement): replace inputs in their existing grid
|
||||
// slot without auto-selecting or moving the outputs to the front, so a
|
||||
// finished policy run doesn't yank the file to the top or open it.
|
||||
if (silent) {
|
||||
return processFileSwapInPlace(state, inputFileIds, provenancedOutputs);
|
||||
}
|
||||
|
||||
return processFileSwap(state, inputFileIds, provenancedOutputs);
|
||||
}
|
||||
|
||||
case "UNDO_CONSUME_FILES": {
|
||||
|
||||
@@ -21,6 +21,10 @@ import { fileStorage } from "@app/services/fileStorage";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { FileAnalyzer } from "@app/services/fileAnalyzer";
|
||||
import { trackPdfUploaded } from "@app/services/analytics";
|
||||
import {
|
||||
reportBulkAddProgress,
|
||||
clearBulkAddProgress,
|
||||
} from "@app/services/bulkAddProgress";
|
||||
const DEBUG = process.env.NODE_ENV === "development";
|
||||
const HYDRATION_CONCURRENCY = 2;
|
||||
let activeHydrations = 0;
|
||||
@@ -358,11 +362,42 @@ export async function addFiles(
|
||||
// Collect hydrations to schedule after dispatch so updateStirlingFileStub finds files in state.
|
||||
const pendingHydrations: Array<() => Promise<void>> = [];
|
||||
|
||||
// Stream the batch into the workspace in chunks. The per-file pre-scan below
|
||||
// (dedupe, encryption sniff — which reads each PDF's bytes) takes real time
|
||||
// for a big folder drop; a single end-of-loop dispatch would leave the UI
|
||||
// frozen-looking for seconds and then dump hundreds of rows in one render.
|
||||
// Chunked dispatch keeps rows (and their thumbnail hydrations) streaming in,
|
||||
// and the progress store drives the sidebar's "Adding files…" indicator.
|
||||
const DISPATCH_CHUNK = 25;
|
||||
let flushedStubs = 0;
|
||||
let flushedHydrations = 0;
|
||||
const flushChunk = () => {
|
||||
if (
|
||||
!options.skipWorkspaceDispatch &&
|
||||
stirlingFileStubs.length > flushedStubs
|
||||
) {
|
||||
dispatch({
|
||||
type: "ADD_FILES",
|
||||
payload: { stirlingFileStubs: stirlingFileStubs.slice(flushedStubs) },
|
||||
});
|
||||
flushedStubs = stirlingFileStubs.length;
|
||||
}
|
||||
// Hydrations only after their chunk is dispatched, so
|
||||
// updateStirlingFileStub finds the files in state.
|
||||
while (flushedHydrations < pendingHydrations.length) {
|
||||
scheduleMetadataHydration(pendingHydrations[flushedHydrations++]);
|
||||
}
|
||||
};
|
||||
|
||||
reportBulkAddProgress(0, filesToProcess.length);
|
||||
let scannedCount = 0;
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (!allowDuplicates && existingQuickKeys.has(quickKey)) {
|
||||
reportBulkAddProgress(++scannedCount, filesToProcess.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -398,26 +433,28 @@ export async function addFiles(
|
||||
try {
|
||||
const { pendingFilePathMappings } =
|
||||
await import("@app/services/pendingFilePathMappings");
|
||||
console.log(
|
||||
`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`,
|
||||
);
|
||||
console.log(
|
||||
`[FileActions] Available mappings:`,
|
||||
Array.from(pendingFilePathMappings.keys()),
|
||||
);
|
||||
// DEBUG-gated: these fire per file, and a 300-file drop emitting 4 log
|
||||
// lines each measurably stalls the main thread with devtools open.
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`,
|
||||
);
|
||||
}
|
||||
const localFilePath = pendingFilePathMappings.get(quickKey);
|
||||
if (localFilePath) {
|
||||
console.log(`[FileActions] ✓ Found localFilePath: ${localFilePath}`);
|
||||
if (DEBUG)
|
||||
console.log(
|
||||
`[FileActions] ✓ Found localFilePath: ${localFilePath}`,
|
||||
);
|
||||
fileStub.localFilePath = localFilePath;
|
||||
pendingFilePathMappings.delete(quickKey); // Clean up after use
|
||||
console.log(
|
||||
`[FileActions] Applied localFilePath to file: ${file.name}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`[FileActions] ✗ No localFilePath found for this file`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[FileActions] Could not check for localFilePath:", error);
|
||||
if (DEBUG)
|
||||
console.log(
|
||||
"[FileActions] Could not check for localFilePath:",
|
||||
error,
|
||||
);
|
||||
// FileManagerContext may not be available in all contexts
|
||||
}
|
||||
|
||||
@@ -505,17 +542,15 @@ export async function addFiles(
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
reportBulkAddProgress(++scannedCount, filesToProcess.length);
|
||||
if (stirlingFileStubs.length - flushedStubs >= DISPATCH_CHUNK) {
|
||||
flushChunk();
|
||||
}
|
||||
}
|
||||
|
||||
// Batch dispatch in one render. Suppressed by skipWorkspaceDispatch.
|
||||
if (stirlingFileStubs.length > 0 && !options.skipWorkspaceDispatch) {
|
||||
dispatch({ type: "ADD_FILES", payload: { stirlingFileStubs } });
|
||||
}
|
||||
|
||||
// Schedule hydrations after dispatch so updateStirlingFileStub finds files in state
|
||||
for (const task of pendingHydrations) {
|
||||
scheduleMetadataHydration(task);
|
||||
}
|
||||
// Flush the remainder (also the sole dispatch for small batches).
|
||||
flushChunk();
|
||||
|
||||
// Persist to storage if enabled using fileStorage service
|
||||
if (enablePersistence && stirlingFiles.length > 0) {
|
||||
@@ -550,6 +585,7 @@ export async function addFiles(
|
||||
|
||||
return stirlingFiles;
|
||||
} finally {
|
||||
clearBulkAddProgress();
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
@@ -565,6 +601,10 @@ export async function consumeFiles(
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
// Silent: replace the input in place (same grid slot) without auto-selecting
|
||||
// or reordering the output. Used by background enforcement (policy auto-run)
|
||||
// so a finished file updates in place instead of jumping to the top / opening.
|
||||
options?: { silent?: boolean },
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG)
|
||||
console.log(
|
||||
@@ -611,6 +651,7 @@ export async function consumeFiles(
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputStirlingFileStubs: outputStirlingFileStubs,
|
||||
silent: options?.silent ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Resolve a classification label id to a human display name. Classification is a
|
||||
// SaaS-only feature, so the core build has no vocabulary and returns the id
|
||||
// unchanged; the proprietary build overrides this to map ids to (translatable)
|
||||
// names. Core files never carry classification labels, so the identity fallback
|
||||
// is never actually rendered here.
|
||||
|
||||
/** Returns a resolver mapping a label id to its display name. */
|
||||
export function useLabelName(): (id: string) => string {
|
||||
return (id) => id;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Whether document classification (and everything it drives in the UI: the
|
||||
// Files-sidebar category grouping, per-file label chips, and the file-details
|
||||
// Classification section) is active in this build. Classification is a
|
||||
// SaaS-only feature gated on the AI engine, so core — and every build that
|
||||
// doesn't override this seam (proprietary, desktop, cloud) — returns false, and
|
||||
// none of that UI ever renders. The saas layer overrides it to track the AI
|
||||
// engine's enabled flag, so the feature shows up only on SaaS when AI is on.
|
||||
|
||||
export function useClassificationEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -6,6 +6,31 @@ import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
|
||||
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
// Global gate on concurrent lazy generations. Each one loads the file's FULL
|
||||
// bytes from IndexedDB and renders a thumbnail — when a big list mounts (e.g. a
|
||||
// 300-file folder drop), letting every row start at once stampedes IndexedDB
|
||||
// and the main thread, exactly when uploads/policies are also working. Queue
|
||||
// FIFO at a small concurrency instead; rows fill in progressively.
|
||||
const LAZY_THUMB_CONCURRENCY = 2;
|
||||
let activeLazyThumbs = 0;
|
||||
const lazyThumbQueue: Array<() => Promise<void>> = [];
|
||||
|
||||
function scheduleLazyThumb(task: () => Promise<void>): void {
|
||||
lazyThumbQueue.push(task);
|
||||
drainLazyThumbQueue();
|
||||
}
|
||||
|
||||
function drainLazyThumbQueue(): void {
|
||||
if (activeLazyThumbs >= LAZY_THUMB_CONCURRENCY) return;
|
||||
const next = lazyThumbQueue.shift();
|
||||
if (!next) return;
|
||||
activeLazyThumbs++;
|
||||
void next().finally(() => {
|
||||
activeLazyThumbs--;
|
||||
drainLazyThumbQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the stub's thumbnail if present; otherwise pull bytes from IndexedDB,
|
||||
* generate one, persist it, and update the stub. Server-only files with no
|
||||
@@ -31,7 +56,10 @@ export function useLazyThumbnail(
|
||||
attempted.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
scheduleLazyThumb(async () => {
|
||||
// Row unmounted (or a hydration delivered the thumbnail) while this sat
|
||||
// in the queue — skip the expensive byte load entirely.
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const file = await indexedDB.loadFile(fileId);
|
||||
if (!file || cancelled) return;
|
||||
@@ -43,7 +71,7 @@ export function useLazyThumbnail(
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
|
||||
|
||||
/** Minimal provenance shape needed to resolve a file's inherited badges. */
|
||||
export type LineageStub = {
|
||||
id: string;
|
||||
parentFileId?: string;
|
||||
sourceFileIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Policies that have run on each file, keyed by fileId — drives the shield
|
||||
* badges in the file sidebar. Empty in core; the proprietary build shadows this
|
||||
* with an implementation backed by the policy run store.
|
||||
* with an implementation backed by the policy run store. `_extraStubs` lets
|
||||
* callers supply storage-backed stubs so closed files resolve lineage too.
|
||||
*/
|
||||
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
||||
export function usePolicyFileBadges(
|
||||
_extraStubs?: ReadonlyArray<LineageStub>,
|
||||
): Map<string, FileItemPolicyRef[]> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy currently working on each file, keyed by fileId — drives the
|
||||
* file-row processing spinner. Empty in core; the proprietary build shadows this
|
||||
* with an implementation backed by the policy run store.
|
||||
*/
|
||||
export function usePolicyFileProcessing(): Map<string, FileItemPolicyRef> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Lightweight external store reporting bulk-add progress ("adding file X of Y").
|
||||
*
|
||||
* `addFiles` reads every file during its pre-dispatch scan (ZIP detection,
|
||||
* encryption probe), which for a 200–300 file drop takes real time — without
|
||||
* feedback the app looks frozen. This store lets the Files sidebar render a
|
||||
* slim progress row during that window without threading new state through the
|
||||
* file reducer (whose `ui.isProcessing` drives blocking editor overlays we
|
||||
* don't want for a background-feeling bulk add).
|
||||
*
|
||||
* Read with {@code useSyncExternalStore}; written only by the add pipeline.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export interface BulkAddProgress {
|
||||
/** Files fully scanned/queued so far. */
|
||||
done: number;
|
||||
/** Total files in the current add batch. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Null object — no bulk add in progress. */
|
||||
const IDLE: BulkAddProgress = { done: 0, total: 0 };
|
||||
|
||||
let progress: BulkAddProgress = IDLE;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Report scan progress. Call with (0, total) to begin, then per file. */
|
||||
export function reportBulkAddProgress(done: number, total: number): void {
|
||||
progress = { done, total };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Clear the indicator (batch finished or aborted). */
|
||||
export function clearBulkAddProgress(): void {
|
||||
if (progress === IDLE) return;
|
||||
progress = IDLE;
|
||||
emit();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Current bulk-add progress; `total === 0` means idle. Only shows for batches
|
||||
* large enough that the scan is user-visible (small drops finish instantly). */
|
||||
export function useBulkAddProgress(): BulkAddProgress {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => progress,
|
||||
() => IDLE,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Read a file's classification labels from the PDF metadata the classify policy
|
||||
* writes (the `StirlingPDFClassification` Info-dict key). Loading PDF bytes is
|
||||
* expensive, so callers read once and cache the result on the file's stub
|
||||
* (`classificationLabels`); the Files sidebar then groups by label without
|
||||
* re-reading. Returns null for non-PDF / oversized / unclassified files.
|
||||
*/
|
||||
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
export const CLASSIFICATION_METADATA_KEY = "StirlingPDFClassification";
|
||||
|
||||
/** Cap the auto-read by size — never pull a multi-GB file into memory for a label. */
|
||||
const MAX_READ_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Parse the stored classification JSON (the engine response the classify policy
|
||||
* writes verbatim, minus `outcome`: `{"labels": ["Contract", "NDA"]}`) into the
|
||||
* cached stub shape, or null when absent/empty.
|
||||
*/
|
||||
function parseLabelsEntry(value: string): string[] | null {
|
||||
const raw = JSON.parse(value) as Record<string, unknown>;
|
||||
const labels = Array.isArray(raw.labels)
|
||||
? raw.labels.filter(
|
||||
(label): label is string =>
|
||||
typeof label === "string" && label.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
return labels.length > 0 ? labels : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the labels from a File's PDF metadata directly. Used at classify-import
|
||||
* time, when we already hold the labelled output blob, so the labels are stamped
|
||||
* onto the stub deterministically rather than via a later best-effort read.
|
||||
*/
|
||||
export async function readClassificationLabelsFromFile(
|
||||
file: File,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const result = await extractPDFMetadata(file);
|
||||
if (!result.success) return null;
|
||||
const entry = result.metadata.customMetadata.find(
|
||||
(item) => item.key === CLASSIFICATION_METADATA_KEY,
|
||||
);
|
||||
return entry ? parseLabelsEntry(entry.value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the labels from a stub's file, or null when absent, unreadable, empty,
|
||||
* non-PDF, or over the size cap.
|
||||
*/
|
||||
export async function readStubClassificationLabels(
|
||||
stub: StirlingFileStub,
|
||||
): Promise<string[] | null> {
|
||||
if (stub.type && !stub.type.toLowerCase().includes("pdf")) return null;
|
||||
if (stub.size > MAX_READ_BYTES) return null;
|
||||
const file = await fileStorage
|
||||
.getStirlingFile(stub.id as FileId)
|
||||
.catch(() => null);
|
||||
if (!file) return null;
|
||||
return readClassificationLabelsFromFile(file);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
thumbnail?: string;
|
||||
thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL
|
||||
url?: string; // For compatibility with existing components
|
||||
// Cached classification labels — mirrors the stub field so the sidebar can
|
||||
// group by label without re-reading PDF bytes, and it survives versioning.
|
||||
// See StirlingFileStub.classificationLabels.
|
||||
classificationLabels?: string[];
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
@@ -148,6 +152,9 @@ class FileStorageService {
|
||||
|
||||
// Folder organisation (root when null)
|
||||
folderId: stub.folderId ?? null,
|
||||
|
||||
// Cached classification category, if already known (preserved across re-stores).
|
||||
classificationLabels: stub.classificationLabels,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -273,6 +280,7 @@ class FileStorageService {
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
};
|
||||
|
||||
resolve(stub);
|
||||
@@ -332,6 +340,7 @@ class FileStorageService {
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -423,6 +432,7 @@ class FileStorageService {
|
||||
sourceFileIds: record.sourceFileIds,
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
|
||||
@@ -140,6 +140,22 @@
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Categorical accents (sidebar groups, file-type icons, badges). One hue per
|
||||
token; the dark block overrides each with a LIGHTER shade for contrast on
|
||||
dark backgrounds. JS consumers go through utils/accentColors.ts. */
|
||||
--accent-green: #16a34a;
|
||||
--accent-blue: #2563eb;
|
||||
--accent-red: #dc2626;
|
||||
--accent-orange: #ea580c;
|
||||
--accent-purple: #9333ea;
|
||||
--accent-teal: #0d9488;
|
||||
--accent-pink: #db2777;
|
||||
--accent-amber: #d97706;
|
||||
--accent-indigo: #4f46e5;
|
||||
--accent-cyan: #0891b2;
|
||||
--accent-violet: #7c3aed;
|
||||
--accent-gray: #6b7280;
|
||||
|
||||
/* Spacing system */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
@@ -454,6 +470,20 @@
|
||||
--color-gray-800: #e5e7eb;
|
||||
--color-gray-900: #f3f4f6;
|
||||
|
||||
/* Categorical accents: lighter shades of the same hues, for contrast on dark. */
|
||||
--accent-green: #4ade80;
|
||||
--accent-blue: #60a5fa;
|
||||
--accent-red: #f87171;
|
||||
--accent-orange: #fb923c;
|
||||
--accent-purple: #c084fc;
|
||||
--accent-teal: #2dd4bf;
|
||||
--accent-pink: #f472b6;
|
||||
--accent-amber: #fbbf24;
|
||||
--accent-indigo: #818cf8;
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-violet: #a78bfa;
|
||||
--accent-gray: #9ca3af;
|
||||
|
||||
/* Category colors - same as light mode for consistency */
|
||||
--category-color-removal: #ef4444; /* Red for removal tools */
|
||||
--category-color-security: #f59e0b; /* Orange for security tools */
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
--color-amber-light: #fef3c7;
|
||||
--color-amber-border: #fde68a;
|
||||
--color-amber-dark: #92400e;
|
||||
--color-orange: #f97316;
|
||||
--color-orange-light: #fff7ed;
|
||||
--color-orange-border: #fed7aa;
|
||||
--color-orange-dark: #9a3412;
|
||||
|
||||
/* Category accents (theme-stable) */
|
||||
--color-cat-insurance: #0ea5e9;
|
||||
@@ -197,6 +201,10 @@
|
||||
#fbbf24 — identical to the base — which left Mantine's amber hover a
|
||||
no-op in dark mode. */
|
||||
--color-amber-dark: #f59e0b;
|
||||
--color-orange: #fb923c;
|
||||
--color-orange-light: #2a1408;
|
||||
--color-orange-border: #7c2d12;
|
||||
--color-orange-dark: #fdba74;
|
||||
|
||||
--color-bg: #090c14;
|
||||
--color-bg-alt: #0d1120;
|
||||
|
||||
@@ -51,6 +51,16 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||
isDirty?: boolean; // Has unsaved changes (only for files with localFilePath)
|
||||
/**
|
||||
* Cached classification label ids — the source of truth the Files sidebar
|
||||
* groups by (so it never re-reads PDF bytes); resolve to display names via the
|
||||
* label-display seam. Written when the classify policy runs (SaaS) and CARRIED
|
||||
* FORWARD onto every later version via the stub (see {@code createChildStub} +
|
||||
* the CONSUME_FILES reducer), so a second policy or a tool edit keeps the file
|
||||
* in its label groups instead of dropping to "Other". Undefined for
|
||||
* unclassified files / non-SaaS builds.
|
||||
*/
|
||||
classificationLabels?: string[];
|
||||
// Note: File object stored in provider ref, not in state
|
||||
}
|
||||
|
||||
@@ -274,6 +284,9 @@ export type FileContextAction =
|
||||
payload: {
|
||||
inputFileIds: FileId[];
|
||||
outputStirlingFileStubs: StirlingFileStub[];
|
||||
/** Replace inputs in place without auto-selecting/reordering the outputs
|
||||
* (background enforcement). Defaults to false — normal tool behaviour. */
|
||||
silent?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -353,6 +366,7 @@ export interface FileContextActions {
|
||||
inputFileIds: FileId[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
options?: { silent?: boolean },
|
||||
) => Promise<FileId[]>;
|
||||
undoConsumeFiles: (
|
||||
inputFiles: File[],
|
||||
|
||||
@@ -152,6 +152,20 @@ export const Sizes: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* `fontSize` scales the label text independently of `size` (which sets height).
|
||||
* Unset inherits the `size`-derived default (md for a default button).
|
||||
*/
|
||||
export const FontSizes: Story = {
|
||||
render: (args) => (
|
||||
<Wrap>
|
||||
{(["xs", "sm", "md", "lg", "xl"] as const).map((fontSize) => (
|
||||
<Button key={fontSize} {...args} fontSize={fontSize} text={fontSize} />
|
||||
))}
|
||||
</Wrap>
|
||||
),
|
||||
};
|
||||
|
||||
/** Icons are optional and positional: `leftSection`, `rightSection`, or both. */
|
||||
export const WithIcons: Story = {
|
||||
render: (args) => (
|
||||
|
||||
@@ -22,6 +22,12 @@ export type ButtonAccent =
|
||||
| "success"
|
||||
| "warning";
|
||||
export type ButtonSize = "sm" | "md" | "lg" | "xl";
|
||||
/**
|
||||
* Text-size scale, independent of `size` (which sets the control height). Unset
|
||||
* inherits the `size`-derived default — i.e. `md` for a default button — so
|
||||
* existing buttons are unchanged; set it to size the label on its own axis.
|
||||
*/
|
||||
export type ButtonFontSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
/** `between` pins leftSection/label/rightSection to left/center/right (toolbar rows). */
|
||||
export type ButtonJustify = "center" | "start" | "end" | "between";
|
||||
export type ButtonShape = "default" | "circle" | "pill";
|
||||
@@ -30,6 +36,8 @@ type ButtonOwnProps = {
|
||||
variant?: ButtonVariant;
|
||||
accent?: ButtonAccent;
|
||||
size?: ButtonSize;
|
||||
/** Label text size, independent of `size`. Defaults to the `size`-derived value. */
|
||||
fontSize?: ButtonFontSize;
|
||||
justify?: ButtonJustify;
|
||||
shape?: ButtonShape;
|
||||
/** Alternative to children; use one or the other. */
|
||||
@@ -97,12 +105,23 @@ const MANTINE_JUSTIFY: Record<ButtonJustify, string> = {
|
||||
between: "space-between",
|
||||
};
|
||||
|
||||
// Maps the fontSize scale onto Mantine's font-size tokens (xs 12 → xl 20px),
|
||||
// the same tokens `size` resolves to — so fontSize is a drop-in override.
|
||||
const FONT_SIZE_VAR: Record<ButtonFontSize, string> = {
|
||||
xs: "var(--mantine-font-size-xs)",
|
||||
sm: "var(--mantine-font-size-sm)",
|
||||
md: "var(--mantine-font-size-md)",
|
||||
lg: "var(--mantine-font-size-lg)",
|
||||
xl: "var(--mantine-font-size-xl)",
|
||||
};
|
||||
|
||||
const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function ButtonRoot(
|
||||
{
|
||||
variant = "primary",
|
||||
accent = "default",
|
||||
size = "md",
|
||||
fontSize,
|
||||
justify = "center",
|
||||
shape = "default",
|
||||
text,
|
||||
@@ -191,6 +210,11 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
style={{
|
||||
...(accentVars as CSSProperties),
|
||||
...({ "--button-height": CONTROL_HEIGHT[size] } as CSSProperties),
|
||||
// fontSize decouples label text size from `size`; unset leaves Mantine's
|
||||
// size-derived --button-fz in place.
|
||||
...(fontSize
|
||||
? ({ "--button-fz": FONT_SIZE_VAR[fontSize] } as CSSProperties)
|
||||
: {}),
|
||||
// Icon-only: zero the size padding inline so the lone icon centres.
|
||||
...(iconOnly ? ({ "--button-padding-x": "0" } as CSSProperties) : {}),
|
||||
...style,
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
.sui-iconbadge--red {
|
||||
--ib-base: var(--color-red);
|
||||
}
|
||||
.sui-iconbadge--orange {
|
||||
--ib-base: var(--color-orange);
|
||||
}
|
||||
.sui-iconbadge--neutral {
|
||||
--ib-base: var(--color-text-1);
|
||||
background: none;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type IconBadgeAccent =
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "neutral";
|
||||
|
||||
export interface IconBadgeProps {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Shared classification-label pill (team labels editor + sidebar category manager). */
|
||||
|
||||
.sui-labelchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sui-labelchip-name {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sui-labelchip-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-labelchip-remove:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
|
||||
const meta: Meta<typeof LabelChip> = {
|
||||
title: "Primitives/LabelChip",
|
||||
component: LabelChip,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { label: "Invoice", icon: "receipt-long" },
|
||||
argTypes: {
|
||||
label: { control: "text" },
|
||||
icon: { control: "text" },
|
||||
count: { control: "number" },
|
||||
onRemove: { action: "removed" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LabelChip>;
|
||||
|
||||
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
/** With a file count, as shown in the sidebar category manager. */
|
||||
export const WithCount: Story = {
|
||||
args: { label: "Contract", icon: "handshake", count: 12 },
|
||||
};
|
||||
|
||||
/** Removable — the trailing × appears when `onRemove` is set. */
|
||||
export const Removable: Story = {
|
||||
args: { label: "NDA", icon: "lock", onRemove: () => {} },
|
||||
};
|
||||
|
||||
/** Falls back to the default "sell" icon when none is given. */
|
||||
export const DefaultIcon: Story = {
|
||||
args: { label: "Uncategorised label", icon: undefined },
|
||||
};
|
||||
|
||||
/** Long names truncate rather than overflow the pill. */
|
||||
export const LongName: Story = {
|
||||
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
|
||||
};
|
||||
|
||||
export const Row: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
|
||||
<LabelChip label="Invoice" icon="receipt-long" count={12} />
|
||||
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
|
||||
<LabelChip label="Lab report" icon="science" count={2} />
|
||||
<LabelChip label="Payslip" icon="payments" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import "@app/ui/LabelChip.css";
|
||||
|
||||
export interface LabelChipProps {
|
||||
/** The label text. */
|
||||
label: string;
|
||||
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
|
||||
icon?: string;
|
||||
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
|
||||
leading?: ReactNode;
|
||||
/** Optional trailing count (e.g. how many files carry this label). */
|
||||
count?: number;
|
||||
/** Show a trailing `×`; called on click. */
|
||||
onRemove?: () => void;
|
||||
/** Accessible name for the remove button. */
|
||||
removeAriaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A classification-label pill: leading icon (or a custom control like an icon
|
||||
* picker) + name, with an optional count and remove button. The shared look for
|
||||
* every place labels are shown as chips — the team labels editor and the
|
||||
* sidebar category manager both render this so they stay visually identical.
|
||||
*/
|
||||
export function LabelChip({
|
||||
label,
|
||||
icon,
|
||||
leading,
|
||||
count,
|
||||
onRemove,
|
||||
removeAriaLabel,
|
||||
}: LabelChipProps) {
|
||||
return (
|
||||
<span className="sui-labelchip" role="listitem">
|
||||
{leading ?? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={icon || "sell"} width="1rem" />
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-labelchip-name" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="sui-labelchip-count">{count}</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-labelchip-remove"
|
||||
onClick={onRemove}
|
||||
aria-label={removeAriaLabel ?? `Remove ${label}`}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.85rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// The app's shared categorical accent palette. The single source of truth for the VALUES is
|
||||
// theme.css (`--accent-<hue>`), which defines a light and a dark variant per hue — in dark mode
|
||||
// accents get LIGHTER, not darker, so they keep contrast against a dark background. This util is
|
||||
// for the places CSS can't reach directly (inline styles, SVG, cycled group colors).
|
||||
//
|
||||
// `themed` (default true) returns a var() reference that live-adapts to the active theme; pass
|
||||
// `themed: false` only where a literal hex is required (canvas, persisted data, exports) — that
|
||||
// freezes the light-mode value.
|
||||
|
||||
export type AccentHue =
|
||||
| "green"
|
||||
| "blue"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "purple"
|
||||
| "teal"
|
||||
| "pink"
|
||||
| "amber"
|
||||
| "indigo"
|
||||
| "cyan"
|
||||
| "violet"
|
||||
| "gray";
|
||||
|
||||
/** Light-mode hex per hue — fallbacks only; theme.css owns the rendered values. */
|
||||
const LIGHT_HEX: Record<AccentHue, string> = {
|
||||
green: "#16a34a",
|
||||
blue: "#2563eb",
|
||||
red: "#dc2626",
|
||||
orange: "#ea580c",
|
||||
purple: "#9333ea",
|
||||
teal: "#0d9488",
|
||||
pink: "#db2777",
|
||||
amber: "#d97706",
|
||||
indigo: "#4f46e5",
|
||||
cyan: "#0891b2",
|
||||
violet: "#7c3aed",
|
||||
gray: "#6b7280",
|
||||
};
|
||||
|
||||
/** Hues cycled across categorical UI (sidebar groups etc.), in display order. */
|
||||
export const ACCENT_CYCLE: readonly AccentHue[] = [
|
||||
"green",
|
||||
"blue",
|
||||
"red",
|
||||
"orange",
|
||||
"purple",
|
||||
"teal",
|
||||
"pink",
|
||||
"amber",
|
||||
"indigo",
|
||||
"cyan",
|
||||
];
|
||||
|
||||
export interface AccentColorOptions {
|
||||
/** Adapt to the active theme (lighter shade in dark mode). Default true. */
|
||||
themed?: boolean;
|
||||
}
|
||||
|
||||
/** CSS color for a hue: a theme-adaptive var() by default, or the literal light hex. */
|
||||
export function accentColor(
|
||||
hue: AccentHue,
|
||||
options?: AccentColorOptions,
|
||||
): string {
|
||||
return (options?.themed ?? true)
|
||||
? `var(--accent-${hue}, ${LIGHT_HEX[hue]})`
|
||||
: LIGHT_HEX[hue];
|
||||
}
|
||||
|
||||
/** The nth categorical accent, wrapping around the cycle. */
|
||||
export function accentCycleColor(
|
||||
index: number,
|
||||
options?: AccentColorOptions,
|
||||
): string {
|
||||
return accentColor(ACCENT_CYCLE[index % ACCENT_CYCLE.length], options);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Turn a human label into a url/id-safe slug: lowercased, with every run of
|
||||
* non-alphanumerics collapsed to a single hyphen and leading/trailing hyphens
|
||||
* trimmed. May return an empty string (e.g. an all-symbol input); callers that
|
||||
* need a non-empty id should supply their own fallback.
|
||||
*/
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@@ -162,6 +162,11 @@ interface AiWorkflowResultFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
/**
|
||||
* Index into the files we sent that this output was derived from, or null/undefined when it has
|
||||
* no single source (merge, generated file). Used to replace that input in place as a new version.
|
||||
*/
|
||||
sourceIndex?: number | null;
|
||||
}
|
||||
|
||||
interface AiWorkflowResponse {
|
||||
@@ -432,9 +437,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Import the files produced by an AI workflow result into FileContext.
|
||||
//
|
||||
// If the workflow produced the same number of outputs as inputs, map each output to its
|
||||
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
|
||||
// add the outputs as new root files.
|
||||
// Each output carries a sourceIndex telling us which input it came from. An input that produced
|
||||
// exactly one output is replaced in place as a new version of that file; everything else (merge,
|
||||
// split, generated files, or an input that produced nothing) is added as a fresh root and leaves
|
||||
// the original files untouched — we never remove or deselect a file the workflow didn't clearly
|
||||
// transform 1:1.
|
||||
const importResultFile = useCallback(
|
||||
async (
|
||||
result: AiWorkflowResponse,
|
||||
@@ -456,27 +463,43 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const files = await Promise.all(descriptors.map(downloadFile));
|
||||
|
||||
if (sourceStubs.length > 0) {
|
||||
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
||||
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
||||
// merge/split they're fresh roots.
|
||||
const operation: ToolOperation = {
|
||||
toolId: "ai-workflow",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const isVersionMapping = files.length === sourceStubs.length;
|
||||
const stubs = files.map((file, i) =>
|
||||
isVersionMapping
|
||||
? createChildStub(sourceStubs[i], operation, file)
|
||||
: createNewStirlingFileStub(file),
|
||||
);
|
||||
// Resolve each output to the input it came from (sourceIndex, from the backend).
|
||||
const sourceForOutput = descriptors.map((descriptor) => {
|
||||
const idx = descriptor.sourceIndex;
|
||||
return typeof idx === "number" && idx >= 0 && idx < sourceStubs.length
|
||||
? sourceStubs[idx]
|
||||
: null;
|
||||
});
|
||||
// Only replace a source in place when it maps to exactly one output (a clean 1:1 transform).
|
||||
// A split (one input → many outputs) or a source shared by several outputs stays a set of
|
||||
// fresh roots so we don't collapse them onto one version chain.
|
||||
const outputsPerSource = new Map<StirlingFileStub["id"], number>();
|
||||
for (const source of sourceForOutput) {
|
||||
if (source) {
|
||||
outputsPerSource.set(
|
||||
source.id,
|
||||
(outputsPerSource.get(source.id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
const consumedIds: StirlingFileStub["id"][] = [];
|
||||
const stubs = files.map((file, i) => {
|
||||
const source = sourceForOutput[i];
|
||||
if (source && outputsPerSource.get(source.id) === 1) {
|
||||
consumedIds.push(source.id);
|
||||
return createChildStub(source, operation, file);
|
||||
}
|
||||
return createNewStirlingFileStub(file);
|
||||
});
|
||||
const stirlingFiles = files.map((file, i) =>
|
||||
createStirlingFile(file, stubs[i].id),
|
||||
);
|
||||
await fileActions.consumeFiles(
|
||||
sourceStubs.map((s) => s.id),
|
||||
stirlingFiles,
|
||||
stubs,
|
||||
);
|
||||
// Consume only the inputs we actually versioned; unrelated files are left in place.
|
||||
await fileActions.consumeFiles(consumedIds, stirlingFiles, stubs);
|
||||
} else {
|
||||
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
|
||||
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The Classification policy's team-labels control, shown in its Edit-Settings
|
||||
* view: a compact summary (count + chips) with an Expand button that opens the
|
||||
* fat {@link LabelsEditorModal}. Team-shared; only users who can configure
|
||||
* policies may edit it. Owns the editable draft and the load/save/import/export
|
||||
* wiring via {@link useClassificationLabels}.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Chip } from "@app/ui/Chip";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
|
||||
import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal";
|
||||
import {
|
||||
downloadLabels,
|
||||
parseLabelsFile,
|
||||
validateLabels,
|
||||
} from "@app/services/labelsFile";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
import "@app/components/policies/LabelsEditor.css";
|
||||
|
||||
/** Chips shown in the collapsed team summary before it gets noisy. */
|
||||
const SUMMARY_CHIP_COUNT = 12;
|
||||
|
||||
interface ClassificationLabelsSectionProps {
|
||||
canConfigure: boolean;
|
||||
}
|
||||
|
||||
export function ClassificationLabelsSection({
|
||||
canConfigure,
|
||||
}: ClassificationLabelsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { teamLabels, isCustom, loading, saving, error, saveTeam } =
|
||||
useClassificationLabels(true);
|
||||
|
||||
const [draft, setDraft] = useState<ClassificationLabel[]>(teamLabels);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft to server truth whenever it changes (load / save / reset).
|
||||
// Local edits don't change `teamLabels`, so this never clobbers them mid-edit.
|
||||
useEffect(() => setDraft(teamLabels), [teamLabels]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(teamLabels),
|
||||
[draft, teamLabels],
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
setDraft(teamLabels);
|
||||
setLocalError(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onImportFile = (file: File) => {
|
||||
setLocalError(null);
|
||||
void parseLabelsFile(file)
|
||||
.then(setDraft)
|
||||
.catch((e: unknown) =>
|
||||
setLocalError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("policies.labels.importError", "Couldn't import that file."),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const errors = validateLabels({ labels: draft });
|
||||
if (errors.length > 0) {
|
||||
setLocalError(errors[0]);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void saveTeam(draft).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-summary">
|
||||
<p className="pol-section-label">
|
||||
{t("policies.labels.sectionLabel", "Classification labels")}
|
||||
</p>
|
||||
<Card>
|
||||
{loading ? (
|
||||
<span className="labels-empty">{t("loading", "Loading…")}</span>
|
||||
) : (
|
||||
<div className="labels-summary">
|
||||
<div className="labels-summary-stats">
|
||||
<span>
|
||||
<strong>{teamLabels.length}</strong>{" "}
|
||||
{t("policies.labels.teamCount", "team labels")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="labels-chips">
|
||||
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
|
||||
<Chip key={label.id} accent="neutral" size="sm">
|
||||
{label.name}
|
||||
</Chip>
|
||||
))}
|
||||
{teamLabels.length > SUMMARY_CHIP_COUNT && (
|
||||
<Chip accent="neutral" size="sm">
|
||||
+{teamLabels.length - SUMMARY_CHIP_COUNT}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="labels-summary-note">
|
||||
{isCustom
|
||||
? t("policies.labels.customNote", "Customized for your team.")
|
||||
: t(
|
||||
"policies.labels.defaultNote",
|
||||
"Using the built-in default, shared with your team.",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{canConfigure
|
||||
? t("policies.labels.edit", "Edit labels")
|
||||
: t("policies.labels.view", "View labels")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={t(
|
||||
"policies.labels.managedNote",
|
||||
"Team labels are managed by your team leader.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !open && <Banner tone="danger" description={error} />}
|
||||
|
||||
<LabelsEditorModal
|
||||
open={open}
|
||||
onClose={close}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onImportFile={onImportFile}
|
||||
onExport={() => downloadLabels(draft)}
|
||||
onReset={() => {
|
||||
// Stage the built-in default into the draft — reversible via Cancel,
|
||||
// only persisted on Save (no immediate destructive server delete).
|
||||
setLocalError(null);
|
||||
setDraft(DEFAULT_CLASSIFICATION_LABELS);
|
||||
}}
|
||||
onClear={() => {
|
||||
// Stage an empty list to build from scratch — also reversible until Save.
|
||||
setLocalError(null);
|
||||
setDraft([]);
|
||||
}}
|
||||
onSave={onSave}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
readOnly={!canConfigure}
|
||||
error={localError ?? error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Small popover picker for a classification label's icon: a trigger button
|
||||
* showing the current icon, opening a grid of the curated
|
||||
* {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material
|
||||
* Symbols). No search yet — the palette is small enough to scan.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, SimpleGrid, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
|
||||
interface LabelIconPickerProps {
|
||||
/** Current icon key, or undefined to show the default placeholder. */
|
||||
value?: string;
|
||||
onChange: (icon: string) => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function LabelIconPicker({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: LabelIconPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={open}
|
||||
onChange={setOpen}
|
||||
onDismiss={() => setOpen(false)}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
trapFocus
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="labels-icon-pick"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
|
||||
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
|
||||
{LABEL_ICON_OPTIONS.map((option) => {
|
||||
const iconLabel = t(
|
||||
`policies.labels.iconName.${option.icon}`,
|
||||
option.label,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={option.icon}
|
||||
label={iconLabel}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
|
||||
onClick={() => {
|
||||
onChange(option.icon);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label={t(
|
||||
"policies.labels.pickIcon",
|
||||
"Use {{label}} icon",
|
||||
{
|
||||
label: iconLabel,
|
||||
},
|
||||
)}
|
||||
aria-pressed={option.icon === value}
|
||||
>
|
||||
<LocalIcon icon={option.icon} width="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* Classification-labels editor: add box, chip grid, icon picker, and the
|
||||
summary/modal chrome around them. */
|
||||
|
||||
.labels-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Add box ---- */
|
||||
.labels-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.labels-add-input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
.labels-add-error {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-red);
|
||||
}
|
||||
.labels-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */
|
||||
.labels-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Icon picker (trigger + popover options) ---- */
|
||||
.labels-icon-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-pick:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-option:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option.is-selected {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
/* ---- Section summary (Edit-Settings card) ---- */
|
||||
.labels-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.labels-summary-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Fat modal ---- */
|
||||
.labels-modal {
|
||||
width: min(1100px, 94vw);
|
||||
max-width: min(1100px, 94vw);
|
||||
}
|
||||
.labels-modal-body {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 0;
|
||||
}
|
||||
.labels-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
.labels-footer-left,
|
||||
.labels-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Grouped mode: chips under collapsible parent categories ---- */
|
||||
.labels-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.labels-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1, inherit);
|
||||
}
|
||||
.labels-group-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.labels-group-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3, rgba(128, 128, 128, 0.9));
|
||||
}
|
||||
.labels-group-ungrouped {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2, inherit);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list.
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
|
||||
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
import {
|
||||
getSidebarCategories,
|
||||
subscribeSidebarCategories,
|
||||
} from "@app/services/fileSidebarCategories";
|
||||
import {
|
||||
labelId,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
|
||||
interface LabelsEditorProps {
|
||||
value: ClassificationLabel[];
|
||||
onChange: (next: ClassificationLabel[]) => void;
|
||||
readOnly?: boolean;
|
||||
/** Names that can't be added here because another set already owns them. */
|
||||
reservedNames?: string[];
|
||||
addPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
/** Render chips under collapsible parent categories (like the sidebar picker). */
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export function LabelsEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
reservedNames,
|
||||
addPlaceholder,
|
||||
emptyText,
|
||||
grouped = false,
|
||||
}: LabelsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pending, setPending] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
const add = () => {
|
||||
const name = pending.trim();
|
||||
if (!name) return;
|
||||
if (name.length > MAX_TEXT_LENGTH) {
|
||||
setAddError(
|
||||
t(
|
||||
"policies.labels.tooLong",
|
||||
"Labels can be at most {{max}} characters.",
|
||||
{ max: MAX_TEXT_LENGTH },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
const taken =
|
||||
value.some((label) => label.name.toLowerCase() === key) ||
|
||||
(reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key);
|
||||
if (taken) {
|
||||
setAddError(
|
||||
t("policies.labels.duplicate", '"{{name}}" already exists.', { name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
setPending("");
|
||||
onChange([...value, { id: labelId(name), name }]);
|
||||
};
|
||||
|
||||
const removeById = (id: string) =>
|
||||
onChange(value.filter((label) => label.id !== id));
|
||||
|
||||
const setIconById = (id: string, icon: string) =>
|
||||
onChange(
|
||||
value.map((label) => (label.id === id ? { ...label, icon } : label)),
|
||||
);
|
||||
|
||||
const renderChip = (label: ClassificationLabel) => {
|
||||
const key = label.id;
|
||||
return (
|
||||
<LabelChip
|
||||
key={key}
|
||||
label={label.name}
|
||||
leading={
|
||||
readOnly ? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
|
||||
</span>
|
||||
) : (
|
||||
<LabelIconPicker
|
||||
value={label.icon}
|
||||
onChange={(icon) => setIconById(key, icon)}
|
||||
ariaLabel={t(
|
||||
"policies.labels.iconAria",
|
||||
"Choose an icon for {{name}}",
|
||||
{ name: label.name },
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onRemove={readOnly ? undefined : () => removeById(key)}
|
||||
removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", {
|
||||
name: label.name,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-editor">
|
||||
{!readOnly && (
|
||||
<div className="labels-add">
|
||||
<input
|
||||
className="labels-add-input"
|
||||
value={pending}
|
||||
maxLength={MAX_TEXT_LENGTH + 1}
|
||||
placeholder={
|
||||
addPlaceholder ??
|
||||
t("policies.labels.addPlaceholder", "Add a label…")
|
||||
}
|
||||
onChange={(e) => {
|
||||
setPending(e.target.value);
|
||||
if (addError) setAddError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={add}
|
||||
disabled={!pending.trim()}
|
||||
>
|
||||
{t("policies.labels.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{addError && <p className="labels-add-error">{addError}</p>}
|
||||
|
||||
{value.length === 0 ? (
|
||||
<p className="labels-empty">
|
||||
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
|
||||
</p>
|
||||
) : grouped ? (
|
||||
<GroupedLabels value={value} renderChip={renderChip} />
|
||||
) : (
|
||||
<div className="labels-chips" role="list">
|
||||
{value.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupedLabelsProps {
|
||||
value: ClassificationLabel[];
|
||||
renderChip: (label: ClassificationLabel) => ReactNode;
|
||||
}
|
||||
|
||||
/** Chips laid out under collapsible parent categories (device-local structure). */
|
||||
function GroupedLabels({ value, renderChip }: GroupedLabelsProps) {
|
||||
const { t } = useTranslation();
|
||||
const categories = useSyncExternalStore(
|
||||
subscribeSidebarCategories,
|
||||
getSidebarCategories,
|
||||
);
|
||||
|
||||
// Category sections that actually contain labels from `value`, plus the leftovers.
|
||||
const { sections, ungrouped } = useMemo(() => {
|
||||
const byId = new Map(value.map((l) => [l.id, l]));
|
||||
const claimed = new Set<string>();
|
||||
const sections = categories
|
||||
.map((category) => {
|
||||
const members = category.labelKeys
|
||||
.map((id) => byId.get(id))
|
||||
.filter((l): l is ClassificationLabel => !!l);
|
||||
members.forEach((m) => claimed.add(m.id));
|
||||
return { category, members };
|
||||
})
|
||||
.filter((s) => s.members.length > 0);
|
||||
const ungrouped = value.filter((l) => !claimed.has(l.id));
|
||||
return { sections, ungrouped };
|
||||
}, [value, categories]);
|
||||
|
||||
// Only the first section starts expanded — a scannable overview.
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
|
||||
() => new Set(sections.length > 0 ? [sections[0].category.id] : []),
|
||||
);
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="labels-groups">
|
||||
{sections.map(({ category, members }) => {
|
||||
const isOpen = expanded.has(category.id);
|
||||
return (
|
||||
<section key={category.id} className="labels-group">
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="labels-group-header"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => toggle(category.id)}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
<LocalIcon icon={category.icon} width="1.05rem" />
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="labels-group-count">{members.length}</span>
|
||||
}
|
||||
>
|
||||
<span className="labels-group-name">{category.name}</span>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="labels-chips" role="list">
|
||||
{members.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="labels-group">
|
||||
<p className="labels-group-ungrouped">
|
||||
{t("policies.labels.ungrouped", "Ungrouped")}
|
||||
</p>
|
||||
<div className="labels-chips" role="list">
|
||||
{ungrouped.map(renderChip)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Full-screen ("fat") editor for the team's classification labels — the roomy
|
||||
* view the settings summary's Edit button opens. Hosts the {@link LabelsEditor}
|
||||
* chip grid plus an Import/Export toolbar and a footer holding the destructive
|
||||
* actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is
|
||||
* staged: nothing is persisted until Save, which stays disabled until the draft
|
||||
* actually changes. The draft is owned by the caller so the settings summary
|
||||
* reflects saved changes.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { Modal } from "@app/ui/Modal";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { LabelsEditor } from "@app/components/policies/LabelsEditor";
|
||||
import type { ClassificationLabel } from "@app/data/classificationLabels";
|
||||
|
||||
interface LabelsEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
draft: ClassificationLabel[];
|
||||
onDraftChange: (next: ClassificationLabel[]) => void;
|
||||
onImportFile: (file: File) => void;
|
||||
onExport: () => void;
|
||||
/** Stage the built-in default into the draft. */
|
||||
onReset: () => void;
|
||||
/** Stage an empty list into the draft (build from scratch). */
|
||||
onClear: () => void;
|
||||
onSave: () => void;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
readOnly: boolean;
|
||||
/** Save (server) or import (file) failure to surface, if any. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function LabelsEditorModal({
|
||||
open,
|
||||
onClose,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onImportFile,
|
||||
onExport,
|
||||
onReset,
|
||||
onClear,
|
||||
onSave,
|
||||
dirty,
|
||||
saving,
|
||||
readOnly,
|
||||
error,
|
||||
}: LabelsEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the same file twice still fires onChange.
|
||||
e.target.value = "";
|
||||
if (file) onImportFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
className="labels-modal"
|
||||
title={t("policies.labels.modalTitle", "Classification labels")}
|
||||
subtitle={t(
|
||||
"policies.labels.modalSubtitle",
|
||||
"Shared with your whole team. The classifier picks the labels that fit each document.",
|
||||
)}
|
||||
footer={
|
||||
<div className="labels-footer">
|
||||
<div className="labels-footer-left">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onClear}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.startFromScratch", "Start from scratch")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.resetToDefault", "Reset to default")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="labels-footer-right">
|
||||
<Button variant="tertiary" size="sm" onClick={onClose}>
|
||||
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? t("policies.labels.saving", "Saving…")
|
||||
: t("policies.labels.saveForTeam", "Save for team")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="labels-modal-body">
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={error}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="labels-toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t("policies.labels.import", "Import JSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onExport}
|
||||
>
|
||||
{t("policies.labels.export", "Export JSON")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LabelsEditor
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
readOnly={readOnly}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,87 @@
|
||||
.pol-row:focus-visible .sui-iconbadge {
|
||||
--ib-accent: var(--ib-base);
|
||||
}
|
||||
|
||||
/* Processing indicator: a spinning ring around the category icon while the
|
||||
policy has runs in flight. The ring carries the category accent; the badge
|
||||
also shows its colour (not the neutral rest tint) so an active policy reads
|
||||
clearly even before hover. */
|
||||
.pol-row-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
--pol-ring: var(--color-blue);
|
||||
}
|
||||
.pol-row-icon[data-accent="purple"] {
|
||||
--pol-ring: var(--color-purple);
|
||||
}
|
||||
.pol-row-icon[data-accent="green"] {
|
||||
--pol-ring: var(--color-green);
|
||||
}
|
||||
.pol-row-icon[data-accent="amber"] {
|
||||
--pol-ring: var(--color-amber);
|
||||
}
|
||||
.pol-row-icon[data-accent="red"] {
|
||||
--pol-ring: var(--color-red);
|
||||
}
|
||||
.pol-row-icon[data-accent="orange"] {
|
||||
--pol-ring: var(--color-orange);
|
||||
}
|
||||
.pol-row-icon.is-processing .sui-iconbadge {
|
||||
--ib-accent: var(--ib-base);
|
||||
}
|
||||
.pol-row-ring {
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid color-mix(in srgb, var(--pol-ring) 20%, transparent);
|
||||
border-top-color: var(--pol-ring);
|
||||
animation: pol-ring-spin 0.7s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Global "Retry failed policies (N)" action under the panel header — one place
|
||||
to resume every file the chain stranded, whichever step failed. Amber, so it
|
||||
reads as "needs attention" without screaming error. */
|
||||
.pol-retry-failed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
width: calc(100% - 2 * var(--space-1_5));
|
||||
margin: 0 var(--space-1_5) var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--color-amber, #d97706);
|
||||
background: color-mix(in srgb, var(--color-amber, #d97706) 12%, transparent);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-retry-failed:hover {
|
||||
background: color-mix(in srgb, var(--color-amber, #d97706) 20%, transparent);
|
||||
}
|
||||
.pol-retry-failed:focus-visible {
|
||||
outline: 2px solid var(--color-amber, #d97706);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@keyframes pol-ring-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pol-row-ring {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
.pol-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -110,6 +191,16 @@
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.pol-row > .mantine-Button-inner {
|
||||
width: 100%;
|
||||
}
|
||||
.pol-row > .mantine-Button-inner > .mantine-Button-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
overflow: visible;
|
||||
}
|
||||
.pol-row-trail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -128,6 +219,81 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Policy settings: per-trigger run-order lists ── */
|
||||
.pol-reorder-section {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.pol-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the
|
||||
drop line reads as one straight rule across the list. */
|
||||
.pol-reorder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
}
|
||||
.pol-reorder-row[data-dragging] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Straight, full-width blue insertion line at the drop position (no curves). */
|
||||
.pol-reorder-row[data-drop="above"]::before,
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--color-blue);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pol-reorder-row[data-drop="above"]::before {
|
||||
top: -1px;
|
||||
}
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
.pol-reorder-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
color: var(--color-text-4);
|
||||
cursor: grab;
|
||||
}
|
||||
.pol-reorder-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pol-reorder-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
/* The drag ghost (a cloned row): the whole row with a full blue outline, kept
|
||||
translucent so the list shows through as it moves. */
|
||||
.pol-reorder-row--ghost {
|
||||
border-radius: var(--radius-lg);
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Empty-state line for a trigger with no policies. */
|
||||
.pol-reorder-empty {
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Retry button on a failed activity row. */
|
||||
/* Expandable error text in the activity feed — long backend errors are clamped
|
||||
and collapsed by default so they don't blow up the row. */
|
||||
@@ -278,6 +444,37 @@
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* A section label rendered as a collapse toggle (Recent Activity): strip the
|
||||
button chrome but keep the .pol-section-label typography, chevron pushed right. */
|
||||
.pol-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pol-section-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-4);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pol-section-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the
|
||||
viewport) then scroll, so a long history doesn't push the stats footer away. */
|
||||
.pol-activity-list {
|
||||
max-height: min(22rem, 45vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||
directly below it carries data-first so the borders don't double up. */
|
||||
.pol-subhead {
|
||||
|
||||
@@ -12,18 +12,39 @@
|
||||
* collapsed; clicking an icon selects the policy and expands the rail.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@mantine/core";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import ReplayRounded from "@mui/icons-material/ReplayRounded";
|
||||
import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded";
|
||||
import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded";
|
||||
import TuneRounded from "@mui/icons-material/TuneRounded";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { getPolicyAutomation } from "@app/services/policyFolders";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import { runsToActivity, runsToStats } from "@app/services/policyLiveData";
|
||||
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
|
||||
import {
|
||||
runsToActivity,
|
||||
runsToStats,
|
||||
progressByCategory,
|
||||
retryableFailedRuns,
|
||||
EMPTY_RUN_PROGRESS,
|
||||
} from "@app/services/policyLiveData";
|
||||
import {
|
||||
removeRun,
|
||||
usePolicyRuns,
|
||||
usePolicyWaveStart,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type {
|
||||
@@ -46,12 +67,15 @@ import { SectionHeader } from "@app/ui/SectionHeader";
|
||||
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
|
||||
import type { PolicyConfigResult } from "@app/types/policies";
|
||||
import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies";
|
||||
import { PanelHeader } from "@app/ui/PanelHeader";
|
||||
import {
|
||||
usePolicySelection,
|
||||
selectPolicy,
|
||||
setPolicyDetailView,
|
||||
closePolicy,
|
||||
openPolicySettings,
|
||||
closePolicySettings,
|
||||
} from "@app/components/policies/policySelectionStore";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
@@ -97,8 +121,8 @@ function promptGuestSignup(): void {
|
||||
* place of the tool list. False when the feature is off or nothing is selected.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
const { selectedId } = usePolicySelection();
|
||||
return POLICIES_ENABLED && selectedId != null;
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
return POLICIES_ENABLED && (selectedId != null || settingsOpen);
|
||||
}
|
||||
|
||||
/** The collapsible policy list, rendered above the Tools section. */
|
||||
@@ -113,6 +137,39 @@ export function PoliciesSection({
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
const guestBlocked = usePolicyGuestBlocked();
|
||||
|
||||
// Live run tallies drive the per-row processing ring + the header summary,
|
||||
// scoped to the current upload wave so they don't accumulate across the whole
|
||||
// persisted run history.
|
||||
const runs = usePolicyRuns();
|
||||
const waveStart = usePolicyWaveStart();
|
||||
const progress = useMemo(
|
||||
() => progressByCategory(runs, waveStart),
|
||||
[runs, waveStart],
|
||||
);
|
||||
// Failed runs still worth retrying, across ALL policies — drives the single
|
||||
// global "Retry failed policies" action. Deliberately not per-policy: a chain
|
||||
// failure strands the file mid-pipeline whichever step failed, and retrying
|
||||
// globally resumes every stranded file (successful steps are never re-run;
|
||||
// completed runs chain onward automatically). NOT wave-scoped: failures from
|
||||
// earlier uploads still count.
|
||||
const retryableRuns = useMemo(() => retryableFailedRuns(runs), [runs]);
|
||||
const retryAllFailed = () => {
|
||||
// Same replace-in-place pattern as the queue-full auto-retry: drop the stale
|
||||
// failed row, fire a fresh run. Queue-full rejections during the burst are
|
||||
// absorbed by the existing backoff.
|
||||
for (const failed of retryableRuns) {
|
||||
const backendId = pol.policies[failed.categoryId]?.backendId;
|
||||
if (!backendId) continue;
|
||||
removeRun(failed.runId);
|
||||
void runPolicyOnFile(
|
||||
failed.categoryId,
|
||||
backendId,
|
||||
failed.fileId as FileId,
|
||||
failed.fileName,
|
||||
);
|
||||
}
|
||||
};
|
||||
// Persist the expand/collapse state across refreshes.
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try {
|
||||
@@ -148,6 +205,13 @@ export function PoliciesSection({
|
||||
(c) => pol.policies[c.id]?.configured,
|
||||
).length;
|
||||
|
||||
// Rows render in execution order (defaults to catalog order until reordered
|
||||
// on the Policy settings page).
|
||||
const displayCategories = [...visibleCategories].sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-list">
|
||||
<div className="pol-list-head">
|
||||
@@ -161,37 +225,70 @@ export function PoliciesSection({
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
<AppTooltip
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.sidebar.infoAriaLabel",
|
||||
"What is a policy?",
|
||||
<Menu position="bottom-end" width="13rem" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.sidebar.optionsAriaLabel",
|
||||
"Policy options",
|
||||
)}
|
||||
>
|
||||
<MoreHorizRounded sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{/* Hovering surfaces the same explanation the info tooltip used to show. */}
|
||||
<AppTooltip
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
position="left"
|
||||
maxWidth="16rem"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<InfoOutlined sx={{ fontSize: "1rem" }} />}
|
||||
>
|
||||
{t("policies.sidebar.whatIsPolicy", "What is a policy?")}
|
||||
</Menu.Item>
|
||||
</AppTooltip>
|
||||
{pol.canConfigure && (
|
||||
<Menu.Item
|
||||
leftSection={<TuneRounded sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => openPolicySettings()}
|
||||
>
|
||||
{t("policies.sidebar.policySettings", "Policy settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--icon-files-color)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</AppTooltip>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{retryableRuns.length > 0 && !guestBlocked && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
className="pol-retry-failed"
|
||||
onClick={retryAllFailed}
|
||||
leftSection={<ReplayRounded sx={{ fontSize: "0.95rem" }} />}
|
||||
>
|
||||
{t(
|
||||
"policies.sidebar.retryFailed",
|
||||
"Retry failed policies ({{count}})",
|
||||
{
|
||||
count: retryableRuns.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="pol-list-rows">
|
||||
{visibleCategories.map((cat) => {
|
||||
{displayCategories.map((cat) => {
|
||||
if (cat.comingSoon) {
|
||||
return (
|
||||
<div key={cat.id} className="pol-row pol-row--soon">
|
||||
@@ -203,8 +300,9 @@ export function PoliciesSection({
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
accent="neutral"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -224,41 +322,76 @@ export function PoliciesSection({
|
||||
);
|
||||
}
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const rowProgress = progress.get(cat.id) ?? EMPTY_RUN_PROGRESS;
|
||||
const isProcessing = rowProgress.running > 0;
|
||||
const icon = (
|
||||
<span
|
||||
className={`pol-row-icon${isProcessing ? " is-processing" : ""}`}
|
||||
data-accent={ROW_ACCENT[cat.id] ?? "blue"}
|
||||
>
|
||||
{isProcessing && (
|
||||
<span className="pol-row-ring" aria-hidden="true" />
|
||||
)}
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="pol-row"
|
||||
leftSection={
|
||||
rowProgress.total > 0 ? (
|
||||
<AppTooltip
|
||||
position="left"
|
||||
content={t(
|
||||
"policies.sidebar.rowProgress",
|
||||
"{{completed}} of {{total}} files processed",
|
||||
{
|
||||
completed: rowProgress.completed,
|
||||
total: rowProgress.total,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</AppTooltip>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<span className="pol-row-trail">
|
||||
{status === "setup" ? (
|
||||
<span className="pol-row-setup">
|
||||
{t("policies.sidebar.setUp", "Set up")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={status === "active" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{t(`policies.status.${status}`, STATUS_LABEL[status])}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className="pol-row-chevron"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
onClick={() =>
|
||||
guestBlocked ? promptGuestSignup() : selectPolicy(cat.id)
|
||||
}
|
||||
>
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
{status === "setup" ? (
|
||||
<span className="pol-row-setup">
|
||||
{t("policies.sidebar.setUp", "Set up")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={status === "active" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{t(`policies.status.${status}`, STATUS_LABEL[status])}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className="pol-row-chevron"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -269,11 +402,25 @@ export function PoliciesSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takeover dispatcher: shows the policy-settings page (execution order), an open
|
||||
* policy's detail, or nothing — whichever the selection store currently holds.
|
||||
* The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings
|
||||
* page doesn't pay for (or trip over) them.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
if (settingsOpen && selectedId == null) return <PolicySettingsPanel />;
|
||||
if (selectedId == null) return null;
|
||||
return <PolicyOpenDetail />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-policy view — narrative detail, setup wizard, or edit-settings —
|
||||
* which replaces the Tools area while a policy is selected.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
function PolicyOpenDetail() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories, configs, sources, docTypes } = usePolicyCatalog();
|
||||
@@ -470,6 +617,232 @@ export function PolicyDetailTakeover() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Policy settings takeover — reached from the section header's "…" menu.
|
||||
* Each trigger (upload / export) gets its own run-order list, since a chain only
|
||||
* spans policies that fire on the same trigger — reordering one never affects the
|
||||
* other. Admin-only (the menu entry is gated on canConfigure).
|
||||
*/
|
||||
function PolicySettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
|
||||
// Configured, live policies for a trigger, in execution order.
|
||||
const inOrder = (trigger: "upload" | "export") =>
|
||||
categories
|
||||
.filter(
|
||||
(c) =>
|
||||
pol.policies[c.id]?.configured &&
|
||||
!c.comingSoon &&
|
||||
(pol.policies[c.id]?.runOn ?? "upload") === trigger,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
const uploadCats = inOrder("upload");
|
||||
const exportCats = inOrder("export");
|
||||
|
||||
// Order is one global sort key, so persist both groups together (upload first)
|
||||
// to keep each group's members contiguous — the auto-run chain reads relative
|
||||
// order within a trigger.
|
||||
const persist = (uploadIds: string[], exportIds: string[]) =>
|
||||
pol.reorderPolicies([...uploadIds, ...exportIds]);
|
||||
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={<TuneRounded sx={{ fontSize: "1.1rem" }} />}
|
||||
title={t("policies.settings.title", "Policy settings")}
|
||||
onClose={() => closePolicySettings()}
|
||||
closeLabel={t("policies.detail.close", "Close")}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.settings.runOrderDesc",
|
||||
"When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.",
|
||||
)}
|
||||
</p>
|
||||
{/* Both triggers are always shown so the run order for each is explicit,
|
||||
with an empty note when a trigger has no policies. */}
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onUpload", "On upload")}
|
||||
cats={uploadCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneUpload",
|
||||
"No policies currently run on upload.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
ids,
|
||||
exportCats.map((c) => c.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onExport", "On export")}
|
||||
cats={exportCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneExport",
|
||||
"No policies currently run on export.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
uploadCats.map((c) => c.id),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's run-order list. Rows drag to reorder (only when there's more than
|
||||
* one to order); the drag ghost is the whole row with a blue outline, and a
|
||||
* straight blue line marks where the policy will land. Reorders in isolation and
|
||||
* hands the new id order back to the parent to persist.
|
||||
*/
|
||||
function PolicyReorderSection({
|
||||
title,
|
||||
cats,
|
||||
emptyText,
|
||||
onReorder,
|
||||
}: {
|
||||
title: string;
|
||||
cats: PolicyCategory[];
|
||||
emptyText: string;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
// Whether the drop would land after (vs before) the hovered row.
|
||||
const [overBelow, setOverBelow] = useState(false);
|
||||
const draggable = cats.length >= 2;
|
||||
|
||||
const clear = () => {
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
};
|
||||
|
||||
const handleDrop = (targetId: string) => {
|
||||
if (!dragId || dragId === targetId) return clear();
|
||||
const ids = cats.map((c) => c.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
let to = ids.indexOf(targetId) + (overBelow ? 1 : 0);
|
||||
if (from < 0 || to < 0) return clear();
|
||||
ids.splice(from, 1);
|
||||
if (from < to) to -= 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
onReorder(ids);
|
||||
clear();
|
||||
};
|
||||
|
||||
// The native drag image would be just the grip under the cursor; instead snapshot
|
||||
// the whole row (a styled clone) so the ghost that follows the mouse is the full
|
||||
// row with a blue outline.
|
||||
const startDrag = (e: DragEvent<HTMLSpanElement>, catId: string) => {
|
||||
setDragId(catId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row");
|
||||
if (row instanceof HTMLElement) {
|
||||
const clone = row.cloneNode(true) as HTMLElement;
|
||||
clone.classList.add("pol-reorder-row--ghost");
|
||||
clone.style.width = `${row.offsetWidth}px`;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.top = "-1000px";
|
||||
clone.style.left = "-1000px";
|
||||
clone.style.pointerEvents = "none";
|
||||
document.body.appendChild(clone);
|
||||
e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2);
|
||||
window.setTimeout(() => clone.remove(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (cats.length === 0) {
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<p className="pol-reorder-empty">{emptyText}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<div className="pol-reorder-list">
|
||||
{cats.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="pol-reorder-row"
|
||||
data-dragging={dragId === cat.id || undefined}
|
||||
data-drop={
|
||||
draggable && overId === cat.id && dragId !== cat.id
|
||||
? overBelow
|
||||
? "below"
|
||||
: "above"
|
||||
: undefined
|
||||
}
|
||||
onDragOver={
|
||||
draggable
|
||||
? (e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOverId(cat.id);
|
||||
setOverBelow(e.clientY > rect.top + rect.height / 2);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={
|
||||
draggable
|
||||
? () => setOverId((id) => (id === cat.id ? null : id))
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
draggable
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
handleDrop(cat.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{draggable && (
|
||||
<span
|
||||
className="pol-reorder-grip"
|
||||
draggable
|
||||
onDragStart={(e) => startDrag(e, cat.id)}
|
||||
onDragEnd={clear}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t(
|
||||
"policies.settings.reorderHandle",
|
||||
"Drag to reorder",
|
||||
)}
|
||||
>
|
||||
<DragIndicatorRounded sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-reorder-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-rail policy icons. Each tints blue when active and carries a small
|
||||
* status dot (green active / amber paused). Clicking selects the policy and
|
||||
|
||||
@@ -7,6 +7,7 @@ import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import { PanelHeader } from "@app/ui/PanelHeader";
|
||||
@@ -115,6 +116,7 @@ export function PolicyDetailPanel({
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPaused = status === "paused";
|
||||
const [activityOpen, setActivityOpen] = useState(true);
|
||||
// Real configured steps drive the flow; fall back to the preset's rule labels.
|
||||
const enforceItems =
|
||||
steps && steps.length > 0
|
||||
@@ -181,70 +183,91 @@ export function PolicyDetailPanel({
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</p>
|
||||
{activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title={t("policies.detail.noActivityTitle", "No activity yet")}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="pol-section-label pol-section-toggle"
|
||||
onClick={() => setActivityOpen((o) => !o)}
|
||||
aria-expanded={activityOpen}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`pol-section-chevron${activityOpen ? " is-open" : ""}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</span>
|
||||
</Button>
|
||||
{activityOpen &&
|
||||
(activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
<div className="pol-activity-list">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title={t(
|
||||
"policies.detail.noActivityTitle",
|
||||
"No activity yet",
|
||||
)}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats — one grouped card with divided columns, intentionally
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@app/components/policies/PolicyWorkflowStep";
|
||||
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
import { ClassificationLabelsSection } from "@app/components/policies/ClassificationLabelsSection";
|
||||
|
||||
// Sources are always "editor" for this release, so the Sources step is dropped
|
||||
// from the flow (its panel code is kept below for when other sources return).
|
||||
@@ -103,6 +104,9 @@ export function PolicySetupWizard({
|
||||
// Preset (tool-chain) policies render the locked tool config as their Workflow
|
||||
// step instead of the add/remove builder.
|
||||
const toolChain = getPolicyToolChain(category.id);
|
||||
// A single-tool chain has nothing to toggle/configure, so its config UI is
|
||||
// hidden (kept mounted so the submit trigger still emits that one tool).
|
||||
const singleToolChain = toolChain != null && toolChain.length === 1;
|
||||
const { user } = useAuth();
|
||||
const [step, setStep] = useState(1);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
@@ -337,22 +341,29 @@ export function PolicySetupWizard({
|
||||
<div style={{ display: step === 1 ? undefined : "none" }}>
|
||||
{toolChain ? (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
{/* Single-tool chains have nothing to configure — hide the prompt
|
||||
and the toggle, but keep the step mounted (display:none) so the
|
||||
final submit still emits that one tool. */}
|
||||
{!singleToolChain && (
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: singleToolChain ? "none" : undefined }}>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -371,6 +382,12 @@ export function PolicySetupWizard({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* The Classification policy owns the editable label sets (team-shared
|
||||
+ personal) the classifier picks from. Kept on the first step
|
||||
alongside the tool so it's not buried. */}
|
||||
{category.id === "classification" && (
|
||||
<ClassificationLabelsSection canConfigure={canConfigure} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
|
||||
@@ -83,12 +83,81 @@ describe("policyRunStore", () => {
|
||||
expect(isDispatched("security", "f1")).toBe(true);
|
||||
});
|
||||
|
||||
it("caps stored runs at 50, newest first", () => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i }));
|
||||
it("never evicts in-flight runs, even past the soft cap", () => {
|
||||
// A large upload batch can exceed the cap while still processing. Dropping a
|
||||
// live run would orphan its polling/import and undercount progress, so every
|
||||
// in-flight run is kept regardless of the cap.
|
||||
for (let i = 0; i < 210; i++) {
|
||||
recordRunStart(
|
||||
rec({
|
||||
runId: `r${i}`,
|
||||
fileId: `f${i}`,
|
||||
status: "PENDING",
|
||||
startedAt: i,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const runs = read("stirling-policy-runs").runs;
|
||||
expect(runs).toHaveLength(50);
|
||||
expect(runs[0].runId).toBe("r54"); // most recent
|
||||
expect(runs).toHaveLength(210);
|
||||
expect(runs[0].runId).toBe("r209"); // newest first
|
||||
});
|
||||
|
||||
it("evicts the oldest TERMINAL runs first once over the cap", () => {
|
||||
// Imported, since a COMPLETED-but-not-yet-imported run still counts as
|
||||
// in-flight (see isRunInFlight) and must never be evicted.
|
||||
for (let i = 0; i < 210; i++) {
|
||||
recordRunStart(
|
||||
rec({
|
||||
runId: `r${i}`,
|
||||
fileId: `f${i}`,
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
startedAt: i,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const runs = read("stirling-policy-runs").runs;
|
||||
expect(runs).toHaveLength(200); // trimmed to MAX_RUNS
|
||||
expect(runs[0].runId).toBe("r209"); // newest kept
|
||||
expect(runs.some((r: PolicyRunRecord) => r.runId === "r0")).toBe(false); // oldest dropped
|
||||
});
|
||||
|
||||
it("does not evict a COMPLETED run that hasn't been imported yet, even past the cap", () => {
|
||||
for (let i = 0; i < 210; i++) {
|
||||
recordRunStart(
|
||||
rec({
|
||||
runId: `r${i}`,
|
||||
fileId: `f${i}`,
|
||||
status: "COMPLETED",
|
||||
startedAt: i,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const runs = read("stirling-policy-runs").runs;
|
||||
expect(runs).toHaveLength(210);
|
||||
expect(runs.some((r: PolicyRunRecord) => r.runId === "r0")).toBe(true);
|
||||
});
|
||||
|
||||
describe("processing wave (scopes the panel's progress counts to this upload)", () => {
|
||||
it("begins a new wave when recording with nothing in flight", () => {
|
||||
recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 500 }));
|
||||
expect(read("stirling-policy-runs").waveStartedAt).toBe(500);
|
||||
});
|
||||
|
||||
it("keeps the wave while earlier runs are still in flight", () => {
|
||||
recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 100 }));
|
||||
recordRunStart(rec({ runId: "b", fileId: "fb", startedAt: 200 }));
|
||||
// b joined a's wave (a still PENDING) — the boundary stays at a.
|
||||
expect(read("stirling-policy-runs").waveStartedAt).toBe(100);
|
||||
});
|
||||
|
||||
it("starts a fresh wave once the prior batch has all finished", () => {
|
||||
recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 100 }));
|
||||
// Prior batch completes AND imports → no longer in flight.
|
||||
updateRun("a", { status: "COMPLETED", imported: true });
|
||||
// A new upload after the lull resets the wave to itself.
|
||||
recordRunStart(rec({ runId: "b", fileId: "fb", startedAt: 5000 }));
|
||||
expect(read("stirling-policy-runs").waveStartedAt).toBe(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,11 +59,56 @@ export const POLICY_IN_FLIGHT_STATUSES: readonly PolicyRunStatus[] = [
|
||||
interface RunState {
|
||||
runs: PolicyRunRecord[];
|
||||
dispatched: string[];
|
||||
/** startedAt of the run that began the current processing "wave" — a burst of
|
||||
* runs with no idle gap. Reset whenever a run is recorded while nothing is in
|
||||
* flight. The panel's progress counts (X of Y processed) scope to this so they
|
||||
* reflect the CURRENT upload, not the whole persisted history. */
|
||||
waveStartedAt: number;
|
||||
}
|
||||
|
||||
const IN_FLIGHT_STATUSES: ReadonlySet<PolicyRunStatus> = new Set([
|
||||
"PENDING",
|
||||
"RUNNING",
|
||||
"WAITING_FOR_INPUT",
|
||||
]);
|
||||
|
||||
/** True while a run is still working: dispatched/running, in a retry backoff, or
|
||||
* done on the backend but not yet imported into the workspace. Shared so the
|
||||
* wave tracking, the panel progress, and the file-row spinner all agree. */
|
||||
export function isRunInFlight(run: PolicyRunRecord): boolean {
|
||||
if (run.retrying) return true;
|
||||
if (IN_FLIGHT_STATUSES.has(run.status)) return true;
|
||||
return run.status === "COMPLETED" && !run.imported;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "stirling-policy-runs";
|
||||
/** Cap stored runs so the activity log can't grow without bound. */
|
||||
const MAX_RUNS = 50;
|
||||
/** Soft cap on stored runs so the activity log can't grow without bound. Only
|
||||
* TERMINAL runs are ever evicted (see {@link capRuns}); in-flight runs are always
|
||||
* kept, so a large upload batch (more files than this cap) still polls + imports
|
||||
* every one — and the panel's processing count stays accurate. */
|
||||
const MAX_RUNS = 200;
|
||||
|
||||
const TERMINAL: ReadonlySet<PolicyRunStatus> = new Set([
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"CANCELLED",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Trim the run list toward MAX_RUNS by dropping the OLDEST terminal runs first,
|
||||
* never in-flight ones. Runs are newest-first, so we walk from the tail. If more
|
||||
* than MAX_RUNS runs are still in flight (a very large batch) they're all kept —
|
||||
* dropping a live run would orphan its polling/import and undercount progress.
|
||||
*/
|
||||
function capRuns(runs: PolicyRunRecord[]): PolicyRunRecord[] {
|
||||
if (runs.length <= MAX_RUNS) return runs;
|
||||
const trimmed = [...runs];
|
||||
for (let i = trimmed.length - 1; i >= 0 && trimmed.length > MAX_RUNS; i--) {
|
||||
const run = trimmed[i];
|
||||
if (TERMINAL.has(run.status) && !isRunInFlight(run)) trimmed.splice(i, 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function read(): RunState {
|
||||
try {
|
||||
@@ -88,12 +133,14 @@ function read(): RunState {
|
||||
}))
|
||||
: [],
|
||||
dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [],
|
||||
waveStartedAt:
|
||||
typeof parsed.waveStartedAt === "number" ? parsed.waveStartedAt : 0,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Corrupt/unavailable storage — start empty.
|
||||
}
|
||||
return { runs: [], dispatched: [] };
|
||||
return { runs: [], dispatched: [], waveStartedAt: 0 };
|
||||
}
|
||||
|
||||
let state: RunState = read();
|
||||
@@ -134,11 +181,22 @@ function getSnapshot(): RunState {
|
||||
return state;
|
||||
}
|
||||
|
||||
const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] };
|
||||
const SERVER_SNAPSHOT: RunState = {
|
||||
runs: [],
|
||||
dispatched: [],
|
||||
waveStartedAt: 0,
|
||||
};
|
||||
function getServerSnapshot(): RunState {
|
||||
return SERVER_SNAPSHOT;
|
||||
}
|
||||
|
||||
/** Non-hook check for background work that should yield while a policy wave is
|
||||
* running (e.g. the sidebar's category backfill) — reads the store without
|
||||
* subscribing, so callers don't re-render on every poll tick. */
|
||||
export function hasInFlightPolicyRuns(): boolean {
|
||||
return state.runs.some(isRunInFlight);
|
||||
}
|
||||
|
||||
/** Key identifying a single (policy, file) run attempt. */
|
||||
export function dispatchKey(categoryId: string, fileId: string): string {
|
||||
return `${categoryId}:${fileId}`;
|
||||
@@ -152,11 +210,18 @@ export function isDispatched(categoryId: string, fileId: string): boolean {
|
||||
/** Record a newly-dispatched run (marks it dispatched + adds the record). */
|
||||
export function recordRunStart(record: PolicyRunRecord) {
|
||||
const key = dispatchKey(record.categoryId, record.fileId);
|
||||
// A run recorded while nothing else is in flight begins a fresh wave, so the
|
||||
// progress counts reset to this upload instead of accumulating across every
|
||||
// past upload persisted in localStorage.
|
||||
const waveStartedAt = state.runs.some(isRunInFlight)
|
||||
? state.waveStartedAt
|
||||
: record.startedAt;
|
||||
state = {
|
||||
runs: [record, ...state.runs].slice(0, MAX_RUNS),
|
||||
runs: capRuns([record, ...state.runs]),
|
||||
dispatched: state.dispatched.includes(key)
|
||||
? state.dispatched
|
||||
: [...state.dispatched, key],
|
||||
waveStartedAt,
|
||||
};
|
||||
emit();
|
||||
}
|
||||
@@ -169,7 +234,7 @@ export function recordRunStart(record: PolicyRunRecord) {
|
||||
*/
|
||||
export function addReconciledRun(record: PolicyRunRecord) {
|
||||
if (state.runs.some((r) => r.runId === record.runId)) return;
|
||||
state = { ...state, runs: [record, ...state.runs].slice(0, MAX_RUNS) };
|
||||
state = { ...state, runs: capRuns([record, ...state.runs]) };
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -209,7 +274,7 @@ export function removeRun(runId: string) {
|
||||
|
||||
/** Reset the store — used by tests to isolate it. */
|
||||
export function resetPolicyRuns() {
|
||||
state = { runs: [], dispatched: [] };
|
||||
state = { runs: [], dispatched: [], waveStartedAt: 0 };
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -220,3 +285,13 @@ export function usePolicyRuns(): PolicyRunRecord[] {
|
||||
() => getServerSnapshot().runs,
|
||||
);
|
||||
}
|
||||
|
||||
/** startedAt of the current processing wave (see {@link RunState.waveStartedAt}).
|
||||
* The panel scopes its progress counts to runs at/after this. */
|
||||
export function usePolicyWaveStart(): number {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => getSnapshot().waveStartedAt,
|
||||
() => getServerSnapshot().waveStartedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,16 @@ import type { PolicyDetailView } from "@app/types/policies";
|
||||
interface PolicySelection {
|
||||
selectedId: string | null;
|
||||
detailView: PolicyDetailView;
|
||||
/** The policy-settings page (execution order) takes over the rail. Independent
|
||||
* of {@link selectedId} — it's a section-level view, not tied to one policy. */
|
||||
settingsOpen: boolean;
|
||||
}
|
||||
|
||||
let state: PolicySelection = { selectedId: null, detailView: "detail" };
|
||||
let state: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
@@ -37,6 +44,7 @@ function getSnapshot(): PolicySelection {
|
||||
const SERVER_SNAPSHOT: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
function getServerSnapshot(): PolicySelection {
|
||||
return SERVER_SNAPSHOT;
|
||||
@@ -44,7 +52,7 @@ function getServerSnapshot(): PolicySelection {
|
||||
|
||||
/** Open a policy's detail (resets the sub-view to the narrative). */
|
||||
export function selectPolicy(id: string | null) {
|
||||
state = { selectedId: id, detailView: "detail" };
|
||||
state = { selectedId: id, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -55,6 +63,19 @@ export function setPolicyDetailView(view: PolicyDetailView) {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Open the policy-settings page (execution order). Clears any open policy. */
|
||||
export function openPolicySettings() {
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: true };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the policy-settings page and return to the list. */
|
||||
export function closePolicySettings() {
|
||||
if (!state.settingsOpen) return;
|
||||
state = { ...state, settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the open policy and return to the list. */
|
||||
export function closePolicy() {
|
||||
selectPolicy(null);
|
||||
@@ -62,7 +83,7 @@ export function closePolicy() {
|
||||
|
||||
/** Reset to the initial state — used by tests to isolate the module store. */
|
||||
export function resetPolicySelection() {
|
||||
state = { selectedId: null, detailView: "detail" };
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -22,22 +22,23 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
*/
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
ingestion: "blue",
|
||||
classification: "orange",
|
||||
security: "purple",
|
||||
compliance: "green",
|
||||
routing: "amber",
|
||||
retention: "red",
|
||||
};
|
||||
|
||||
/** Accent name → the CSS colour var the policy badges tint with. */
|
||||
const ACCENT_VAR: Record<string, string> = {
|
||||
blue: "var(--color-blue)",
|
||||
purple: "var(--color-purple)",
|
||||
green: "var(--color-green)",
|
||||
amber: "var(--color-amber)",
|
||||
red: "var(--color-red)",
|
||||
};
|
||||
|
||||
/** CSS colour var for a policy category's accent (blue for unknown categories). */
|
||||
/**
|
||||
* CSS colour var for a policy category's accent (blue for unknown categories) —
|
||||
* the tint used by the file badges and the enforcement overlay.
|
||||
*
|
||||
* Derived straight from the accent name (`--color-<accent>`), which is exactly
|
||||
* the token {@link IconBadge} uses for the same accent. Deriving it (rather than
|
||||
* keeping a second name→var map) means the badge tint can never drift from the
|
||||
* sidebar's colour — previously `orange` was missing from that map, so the
|
||||
* Classification badge/overlay rendered untinted while its sidebar row was orange.
|
||||
*/
|
||||
export function policyAccentVar(categoryId: string): string {
|
||||
return ACCENT_VAR[ROW_ACCENT[categoryId] ?? "blue"];
|
||||
return `var(--color-${ROW_ACCENT[categoryId] ?? "blue"})`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
|
||||
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
|
||||
// by default comes from the preset's defaultOperations, not this list.
|
||||
security: ["redact", "watermark", "sanitize"],
|
||||
// Classification: a single backend step that classifies the document and
|
||||
// writes the result into its metadata.
|
||||
classification: ["classify"],
|
||||
};
|
||||
|
||||
/** The configurable tool chain for a category, or null if it has none yet. */
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
/**
|
||||
* Batch integration test for the policy auto-run orchestration, at the scale the
|
||||
* user hit the bug: 61 files uploaded at once, two active upload policies
|
||||
* (Classification → Security) chained. Drives the REAL policyRunStore + the REAL
|
||||
* hook effects (dispatch → poll → import → chain), mocking only the IO boundaries
|
||||
* (network, storage, thumbnail/stub creation).
|
||||
*
|
||||
* Proves the invariants the user asked for:
|
||||
* - 61 files ⇒ exactly 122 runs (61 classification, then 61 security).
|
||||
* - Delivery is SILENT + in place (consumeFiles called with { silent: true }),
|
||||
* never adding a second copy — the workspace never grows past 61.
|
||||
* - No runaway: if the loop guard regressed, the run count would blow past 122
|
||||
* (or the test would time out), so an exact 122 is a hard regression gate.
|
||||
* - Closing all files mid-run does NOT re-open them: with the workspace emptied,
|
||||
* outputs are delivered to storage (persistVersionedOutputs), never re-added
|
||||
* to the workspace via consumeFiles.
|
||||
*/
|
||||
|
||||
const FILE_COUNT = 61;
|
||||
|
||||
// A tiny mutable "workspace" the mocks share: the list of file stubs currently in
|
||||
// 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 }>,
|
||||
consumeSilentCalls: 0,
|
||||
consumeNonSilentCalls: 0,
|
||||
persistCalls: 0,
|
||||
addFilesCalls: 0,
|
||||
stubCounter: 0,
|
||||
backendOutCounter: 0,
|
||||
bumpRevision: vi.fn(),
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
listPolicyRuns: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
getStirlingFile: vi.fn(),
|
||||
getStirlingFileStub: vi.fn(),
|
||||
persistVersionedOutputs: vi.fn(),
|
||||
updateFileMetadata: vi.fn(),
|
||||
createStirlingFilesAndStubs: vi.fn(),
|
||||
addFiles: vi.fn(),
|
||||
updateStirlingFileStub: vi.fn(),
|
||||
consumeFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs: mocks.workspace }),
|
||||
useFileManagement: () => ({
|
||||
addFiles: mocks.addFiles,
|
||||
updateStirlingFileStub: mocks.updateStirlingFileStub,
|
||||
}),
|
||||
useFileContext: () => ({ consumeFiles: mocks.consumeFiles }),
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: mocks.bumpRevision }),
|
||||
}));
|
||||
vi.mock("@app/hooks/usePolicies", () => ({
|
||||
usePolicies: () => ({
|
||||
policies: {
|
||||
// Classification runs first (order 0), Security second (order 1).
|
||||
classification: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-classification",
|
||||
runOn: "upload",
|
||||
order: 0,
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
},
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-security",
|
||||
runOn: "upload",
|
||||
order: 1,
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: mocks.runStoredPolicy,
|
||||
getPolicyRun: mocks.getPolicyRun,
|
||||
listPolicyRuns: mocks.listPolicyRuns,
|
||||
downloadPolicyOutput: mocks.downloadPolicyOutput,
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFile: mocks.getStirlingFile,
|
||||
getStirlingFileStub: mocks.getStirlingFileStub,
|
||||
persistVersionedOutputs: mocks.persistVersionedOutputs,
|
||||
updateFileMetadata: mocks.updateFileMetadata,
|
||||
},
|
||||
}));
|
||||
vi.mock("@app/services/fileStubHelpers", () => ({
|
||||
createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs,
|
||||
}));
|
||||
vi.mock("@app/services/fileClassification", () => ({
|
||||
readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
usePolicyRuns,
|
||||
resetPolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
|
||||
/** A stable snapshot of the store, read after the flow settles. */
|
||||
let latestRuns: PolicyRunRecord[] = [];
|
||||
function Harness() {
|
||||
usePolicyAutoRun();
|
||||
latestRuns = usePolicyRuns();
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceInWorkspace(inputIds: string[], outputIds: string[]) {
|
||||
mocks.workspace = mocks.workspace
|
||||
.filter((s) => !inputIds.includes(s.id))
|
||||
.concat(outputIds.map((id) => ({ id })));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
vi.clearAllMocks();
|
||||
mocks.consumeSilentCalls = 0;
|
||||
mocks.consumeNonSilentCalls = 0;
|
||||
mocks.persistCalls = 0;
|
||||
mocks.addFilesCalls = 0;
|
||||
mocks.stubCounter = 0;
|
||||
mocks.backendOutCounter = 0;
|
||||
|
||||
mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({
|
||||
id: `file-${i}`,
|
||||
}));
|
||||
|
||||
mocks.listPolicyRuns.mockResolvedValue([]);
|
||||
// A run's bytes are always resolvable (input files + versioned children).
|
||||
mocks.getStirlingFile.mockResolvedValue(
|
||||
new File(["x"], "doc.pdf", { type: "application/pdf" }),
|
||||
);
|
||||
mocks.getStirlingFileStub.mockResolvedValue(null);
|
||||
mocks.persistVersionedOutputs.mockImplementation(async () => {
|
||||
mocks.persistCalls += 1;
|
||||
});
|
||||
mocks.updateFileMetadata.mockResolvedValue(false);
|
||||
mocks.downloadPolicyOutput.mockResolvedValue(
|
||||
new Blob(["x"], { type: "application/pdf" }),
|
||||
);
|
||||
|
||||
// Each dispatch gets a unique run id; the run's single backend output likewise.
|
||||
mocks.runStoredPolicy.mockImplementation(
|
||||
async () => `run-${mocks.stubCounter++}`,
|
||||
);
|
||||
mocks.getPolicyRun.mockImplementation(async (runId: string) => ({
|
||||
runId,
|
||||
policyId: null,
|
||||
status: "COMPLETED",
|
||||
currentStep: 1,
|
||||
stepCount: 1,
|
||||
error: null,
|
||||
outputs: [
|
||||
{
|
||||
fileId: `backend-out-${mocks.backendOutCounter++}`,
|
||||
fileName: "doc.pdf",
|
||||
},
|
||||
],
|
||||
}));
|
||||
// Deliver a unique workspace child stub per output, derived from the parent so
|
||||
// the chain's second policy can find + version it.
|
||||
mocks.createStirlingFilesAndStubs.mockImplementation(
|
||||
async (files: File[], parentStub: { id: string }) => {
|
||||
const stubs = files.map(() => ({
|
||||
id: `${parentStub.id}~${mocks.stubCounter++}`,
|
||||
}));
|
||||
return { stirlingFiles: files, stubs };
|
||||
},
|
||||
);
|
||||
mocks.addFiles.mockImplementation(async (files: File[]) => {
|
||||
mocks.addFilesCalls += 1;
|
||||
return files.map((_f, i) => ({
|
||||
fileId: `added-${mocks.stubCounter++}-${i}`,
|
||||
}));
|
||||
});
|
||||
mocks.consumeFiles.mockImplementation(
|
||||
async (
|
||||
inputIds: string[],
|
||||
_outputs: unknown[],
|
||||
stubs: Array<{ id: string }>,
|
||||
options?: { silent?: boolean },
|
||||
) => {
|
||||
if (options?.silent) mocks.consumeSilentCalls += 1;
|
||||
else mocks.consumeNonSilentCalls += 1;
|
||||
replaceInWorkspace(
|
||||
inputIds,
|
||||
stubs.map((s) => s.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Drive the hook until the store shows the expected number of imported runs. */
|
||||
async function runUntilSettled(expectedRuns: number) {
|
||||
renderHook(() => Harness());
|
||||
await act(async () => {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const imported = latestRuns.filter((r) => r.imported).length;
|
||||
expect(imported).toBe(expectedRuns);
|
||||
},
|
||||
{ timeout: 8000, interval: 20 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("policy auto-run — 61-file batch through a Classification → Security chain", () => {
|
||||
it("produces exactly 122 runs (61 classification, then 61 security)", async () => {
|
||||
await runUntilSettled(FILE_COUNT * 2);
|
||||
|
||||
const classification = latestRuns.filter(
|
||||
(r) => r.categoryId === "classification",
|
||||
);
|
||||
const security = latestRuns.filter((r) => r.categoryId === "security");
|
||||
|
||||
expect(classification).toHaveLength(FILE_COUNT);
|
||||
expect(security).toHaveLength(FILE_COUNT);
|
||||
expect(latestRuns).toHaveLength(FILE_COUNT * 2);
|
||||
});
|
||||
|
||||
it("delivers every output SILENTLY in place — workspace never grows past 61", async () => {
|
||||
await runUntilSettled(FILE_COUNT * 2);
|
||||
|
||||
// 122 deliveries, all silent (background), none via the disruptive path.
|
||||
expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2);
|
||||
expect(mocks.consumeNonSilentCalls).toBe(0);
|
||||
// Never added as brand-new files either.
|
||||
expect(mocks.addFilesCalls).toBe(0);
|
||||
// In-place versioning: each file replaced twice, count unchanged.
|
||||
expect(mocks.workspace).toHaveLength(FILE_COUNT);
|
||||
});
|
||||
|
||||
it("does NOT re-open files that were closed while their runs were in flight", async () => {
|
||||
renderHook(() => Harness());
|
||||
// Close everything immediately — as if the user cleared the workbench mid-run.
|
||||
// The inputs still persist in storage, so getStirlingFileStub resolves them.
|
||||
mocks.getStirlingFileStub.mockResolvedValue({
|
||||
id: "storage",
|
||||
versionNumber: 1,
|
||||
});
|
||||
act(() => {
|
||||
mocks.workspace = [];
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const imported = latestRuns.filter((r) => r.imported).length;
|
||||
expect(imported).toBe(FILE_COUNT * 2);
|
||||
},
|
||||
{ timeout: 8000, interval: 20 },
|
||||
);
|
||||
});
|
||||
|
||||
// Still fully processed (chain intact), but delivered to STORAGE, never
|
||||
// re-added to the workbench — the workspace stays empty.
|
||||
expect(latestRuns).toHaveLength(FILE_COUNT * 2);
|
||||
expect(mocks.workspace).toHaveLength(0);
|
||||
expect(mocks.consumeSilentCalls).toBe(0);
|
||||
expect(mocks.persistCalls).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Two active upload policies, so the auto-run should CHAIN them: fire the first on
|
||||
// the upload, then the second on the first's output. Stub the contexts + network so
|
||||
// we can drive the dispatch against the REAL run store.
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = [];
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs }),
|
||||
useFileManagement: () => ({ addFiles: vi.fn() }),
|
||||
useFileContext: () => ({ consumeFiles: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@app/hooks/usePolicies", () => ({
|
||||
usePolicies: () => ({
|
||||
policies: {
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-sec",
|
||||
runOn: "upload",
|
||||
order: 0,
|
||||
},
|
||||
classification: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-cls",
|
||||
runOn: "upload",
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
resetPolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { runStoredPolicy } from "@app/services/policyApi";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
|
||||
const runStored = vi.mocked(runStoredPolicy);
|
||||
const getFile = vi.mocked(fileStorage.getStirlingFile);
|
||||
|
||||
/** Reset the shared file list between tests without swapping the array identity. */
|
||||
function setFileStubs(next: typeof fileStubs) {
|
||||
fileStubs.length = 0;
|
||||
fileStubs.push(...next);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
setFileStubs([]);
|
||||
runStored.mockReset();
|
||||
getFile.mockReset();
|
||||
getFile.mockResolvedValue({ size: 100 } as never);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("auto-run ordered chaining", () => {
|
||||
it("dispatches only the FIRST ordered policy on upload, not the whole set", async () => {
|
||||
setFileStubs([{ id: "file-1", name: "doc.pdf" }]);
|
||||
runStored.mockResolvedValue("run-sec");
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// The first policy (order 0) runs on the upload; the second waits for the chain.
|
||||
expect(runStored).toHaveBeenCalledTimes(1);
|
||||
expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]);
|
||||
});
|
||||
|
||||
it("chains the next policy onto a completed run's output", async () => {
|
||||
// A first-policy run that has completed and imported its output as file-1-v2.
|
||||
recordRunStart({
|
||||
runId: "run-sec",
|
||||
categoryId: "security",
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 100,
|
||||
target: "saas",
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: 0,
|
||||
});
|
||||
updateRun("run-sec", {
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
outputFileIds: ["file-1-v2"],
|
||||
});
|
||||
runStored.mockResolvedValue("run-cls");
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// The next policy (order 1) fires on the first policy's output, not the original.
|
||||
expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]);
|
||||
});
|
||||
});
|
||||
+22
-9
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
persistVersionedOutputs: vi.fn(),
|
||||
getStirlingFile: vi.fn(),
|
||||
getStirlingFileStub: vi.fn(),
|
||||
updateFileMetadata: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
listPolicyRuns: vi.fn(),
|
||||
createStirlingFilesAndStubs: vi.fn(),
|
||||
@@ -23,7 +24,10 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs: mocks.fileStubs }),
|
||||
useFileManagement: () => ({ addFiles: mocks.addFiles }),
|
||||
useFileManagement: () => ({
|
||||
addFiles: mocks.addFiles,
|
||||
updateStirlingFileStub: vi.fn(),
|
||||
}),
|
||||
useFileContext: () => ({ consumeFiles: mocks.consumeFiles }),
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
@@ -54,6 +58,7 @@ vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: {
|
||||
getStirlingFile: mocks.getStirlingFile,
|
||||
getStirlingFileStub: mocks.getStirlingFileStub,
|
||||
updateFileMetadata: mocks.updateFileMetadata,
|
||||
persistVersionedOutputs: mocks.persistVersionedOutputs,
|
||||
},
|
||||
}));
|
||||
@@ -100,6 +105,7 @@ beforeEach(() => {
|
||||
mocks.listPolicyRuns.mockResolvedValue([]);
|
||||
mocks.getStirlingFileStub.mockResolvedValue(null);
|
||||
mocks.persistVersionedOutputs.mockResolvedValue(undefined);
|
||||
mocks.updateFileMetadata.mockResolvedValue(true);
|
||||
mocks.consumeFiles.mockResolvedValue(undefined);
|
||||
mocks.addFiles.mockResolvedValue([{ fileId: "out-1" }]);
|
||||
mocks.downloadPolicyOutput.mockResolvedValue(
|
||||
@@ -133,17 +139,23 @@ describe("auto-run import: new-version output delivery", () => {
|
||||
expect(mocks.addFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("versions the input in the workspace when it's open (consumeFiles, not a storage write)", async () => {
|
||||
it("versions the input in place SILENTLY when it's open (consumeFiles with silent, + sidebar bump)", async () => {
|
||||
mocks.fileStubs = [{ id: "file-1" }];
|
||||
|
||||
recordCompletedRun();
|
||||
await runImport();
|
||||
|
||||
// Background enforcement must not disturb the workbench: the silent consume
|
||||
// replaces the file in place without auto-selecting / reordering / opening it.
|
||||
expect(mocks.consumeFiles).toHaveBeenCalledWith(
|
||||
["file-1"],
|
||||
expect.any(Array),
|
||||
expect.any(Array),
|
||||
{ silent: true },
|
||||
);
|
||||
// No redundant bump in the in-workspace path — the silent consume's own state
|
||||
// update drives the sidebar refresh (avoids an O(n) IDB re-read per delivery).
|
||||
expect(mocks.bumpRevision).not.toHaveBeenCalled();
|
||||
expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled();
|
||||
expect(mocks.addFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -166,9 +178,12 @@ describe("auto-run import: new-version output delivery", () => {
|
||||
expect(mocks.consumeFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts a server-only run, dating it from the server's createdAt (not now)", async () => {
|
||||
// A run the client never recorded (true orphan): reconcile adopts it from the server. With no
|
||||
// local input link it delivers as a new file, and its age comes from the server, not Date.now().
|
||||
it("adopts a server-only run for visibility, without delivering its outputs", async () => {
|
||||
// A run the client never recorded (true orphan): reconcile adopts it from the server for the
|
||||
// activity feed, dated from the server's createdAt (not Date.now()). It is adopted already
|
||||
// `imported` — with no local input link a delivery would add its output as a NEW workspace
|
||||
// file, and since cap-evicted runs are re-adopted on every refresh, that meant phantom
|
||||
// duplicates opening onto the workbench after each reload.
|
||||
mocks.listPolicyRuns.mockResolvedValue([
|
||||
{
|
||||
runId: "srv-1",
|
||||
@@ -188,10 +203,8 @@ describe("auto-run import: new-version output delivery", () => {
|
||||
});
|
||||
|
||||
expect(getRun("srv-1")?.startedAt).toBe(1000);
|
||||
expect(mocks.addFiles).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ derivedFromTool: true }),
|
||||
);
|
||||
expect(mocks.addFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.downloadPolicyOutput).not.toHaveBeenCalled();
|
||||
expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Auto-run controller: every enabled policy enforces on every uploaded
|
||||
* file. Watches the session's files and, for each (active policy × not-yet-run
|
||||
* file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
|
||||
* to completion, recording progress in {@link policyRunStore} for the activity
|
||||
* feed.
|
||||
* 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
|
||||
@@ -11,7 +15,7 @@
|
||||
* in the run store), so re-renders and remounts don't re-fire.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useAllFiles,
|
||||
useFileManagement,
|
||||
@@ -35,6 +39,7 @@ import type {
|
||||
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 type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { PoliciesByCategory } from "@app/types/policies";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
@@ -120,11 +125,18 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function usePolicyAutoRun(): void {
|
||||
const { fileStubs } = useAllFiles();
|
||||
const { addFiles } = useFileManagement();
|
||||
const { addFiles, updateStirlingFileStub } = useFileManagement();
|
||||
const { consumeFiles } = useFileContext();
|
||||
const { bumpRevision } = useIndexedDB();
|
||||
const { policies } = usePolicies();
|
||||
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.
|
||||
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.
|
||||
const polling = useRef<Set<string>>(new Set());
|
||||
@@ -141,6 +153,33 @@ export function usePolicyAutoRun(): void {
|
||||
// run so a folder-watch burst opens the modal once, not once per file.
|
||||
const firedLimitModal = useRef<Set<string>>(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.
|
||||
const orderedUploadCategories = useMemo(
|
||||
() =>
|
||||
Object.entries(policies)
|
||||
.filter(
|
||||
([, s]) =>
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
(!s.sources ||
|
||||
s.sources.length === 0 ||
|
||||
s.sources.includes("editor")) &&
|
||||
(s.runOn ?? "upload") === "upload",
|
||||
)
|
||||
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map(([id]) => id),
|
||||
[policies],
|
||||
);
|
||||
|
||||
// Runs whose chain-continuation we've already handled this session, so the next
|
||||
// policy is dispatched exactly once per completed run.
|
||||
const chained = useRef<Set<string>>(new Set());
|
||||
|
||||
// Latest policies, read from inside the stable retry callback (which has no deps).
|
||||
const policiesRef = useRef(policies);
|
||||
policiesRef.current = policies;
|
||||
@@ -204,50 +243,75 @@ export function usePolicyAutoRun(): void {
|
||||
[scheduleQueueRetry],
|
||||
);
|
||||
|
||||
// Dispatch: for each active policy × each session file not yet run, fire a run.
|
||||
// Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest
|
||||
// of the chain is dispatched by the chaining effect below, each on the previous
|
||||
// policy's output, so the policies apply cumulatively in order.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
const active = Object.entries(policies).filter(
|
||||
([, s]) =>
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
// Only enforce in the editor when the policy includes "editor" as a source.
|
||||
// runOn is an editor-specific parameter: "upload" fires here, "export" fires
|
||||
// at export time via policyExport. Non-editor sources have their own triggers.
|
||||
(!s.sources ||
|
||||
s.sources.length === 0 ||
|
||||
s.sources.includes("editor")) &&
|
||||
(s.runOn ?? "upload") === "upload",
|
||||
);
|
||||
for (const [categoryId, s] of active) {
|
||||
for (const stub of fileStubs) {
|
||||
// Input-mode policies enforce only on files that actually entered the
|
||||
// system as an upload — not on files a tool/automation produced in-app
|
||||
// (versioned edits or independent artifacts like convert/split/merge).
|
||||
// Those are enforced only by export-mode policies, at export time.
|
||||
if (stub.derivedFromTool) continue;
|
||||
const key = dispatchKey(categoryId, stub.id);
|
||||
// Skip if already run (persisted) or a dispatch is in flight — the
|
||||
// in-memory guard prevents double-firing during the async wait.
|
||||
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
|
||||
continue;
|
||||
}
|
||||
dispatching.current.add(key);
|
||||
const firstCategory = orderedUploadCategories[0];
|
||||
if (!firstCategory) return;
|
||||
const backendId = policies[firstCategory]?.backendId;
|
||||
if (!backendId) return;
|
||||
for (const stub of fileStubs) {
|
||||
// Input-mode policies enforce only on files that actually entered the
|
||||
// system as an upload — not on files a tool/automation produced in-app
|
||||
// (versioned edits or independent artifacts like convert/split/merge).
|
||||
// Those are enforced only by export-mode policies, at export time.
|
||||
if (stub.derivedFromTool) continue;
|
||||
const key = dispatchKey(firstCategory, stub.id);
|
||||
// Skip if already run (persisted) or a dispatch is in flight — the
|
||||
// in-memory guard prevents double-firing during the async wait.
|
||||
if (
|
||||
isDispatched(firstCategory, stub.id) ||
|
||||
dispatching.current.has(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
dispatching.current.add(key);
|
||||
void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name)
|
||||
.catch(() => {
|
||||
// runPolicyOnFile handles its own failures; this is just a backstop
|
||||
// so an unexpected rejection never becomes an unhandled rejection.
|
||||
})
|
||||
.finally(() => dispatching.current.delete(key));
|
||||
}
|
||||
}, [fileStubs, policies, orderedUploadCategories]);
|
||||
|
||||
// Chain: once a run has completed AND its output landed in the workspace, fire the
|
||||
// next upload policy on that output. Only chains on success (a failed run has no
|
||||
// output), and only once per run. isDispatched guards re-dispatch across reloads.
|
||||
useEffect(() => {
|
||||
if (!POLICIES_ENABLED) return;
|
||||
for (const run of runs) {
|
||||
if (run.status !== "COMPLETED" || !run.imported) continue;
|
||||
if (chained.current.has(run.runId)) continue;
|
||||
const nextCategory = nextUploadCategory(
|
||||
orderedUploadCategories,
|
||||
run.categoryId,
|
||||
);
|
||||
const outputIds = run.outputFileIds ?? [];
|
||||
if (!nextCategory || outputIds.length === 0) {
|
||||
// End of the chain (or nothing to chain onto): don't revisit this run.
|
||||
chained.current.add(run.runId);
|
||||
continue;
|
||||
}
|
||||
const backendId = policies[nextCategory]?.backendId;
|
||||
// Next policy not ready yet (still reconciling) — retry when policies change.
|
||||
if (!backendId) continue;
|
||||
chained.current.add(run.runId);
|
||||
// Chain onto EVERY output, not just the first — a run that produced multiple files (split,
|
||||
// ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it.
|
||||
for (const outputId of outputIds) {
|
||||
if (isDispatched(nextCategory, outputId as FileId)) continue;
|
||||
void runPolicyOnFile(
|
||||
categoryId,
|
||||
s.backendId as string,
|
||||
stub.id,
|
||||
stub.name,
|
||||
)
|
||||
.catch(() => {
|
||||
// runPolicyOnFile handles its own failures; this is just a backstop
|
||||
// so an unexpected rejection never becomes an unhandled rejection.
|
||||
})
|
||||
.finally(() => dispatching.current.delete(key));
|
||||
nextCategory,
|
||||
backendId,
|
||||
outputId as FileId,
|
||||
run.fileName,
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [fileStubs, policies]);
|
||||
}, [runs, policies, orderedUploadCategories]);
|
||||
|
||||
// Poll each in-flight run to a terminal state.
|
||||
useEffect(() => {
|
||||
@@ -280,18 +344,32 @@ export function usePolicyAutoRun(): void {
|
||||
const outputMode = policies[run.categoryId]?.outputMode ?? "new_version";
|
||||
const outputName = policies[run.categoryId]?.outputName ?? "";
|
||||
const outputNamePosition = policies[run.categoryId]?.outputNamePosition;
|
||||
const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId);
|
||||
const parentStub = fileStubsRef.current.find(
|
||||
(s) => (s.id as string) === run.fileId,
|
||||
);
|
||||
void importOutputs(run, {
|
||||
addFiles,
|
||||
consumeFiles,
|
||||
updateStirlingFileStub,
|
||||
bumpRevision,
|
||||
outputMode,
|
||||
outputName,
|
||||
outputNamePosition,
|
||||
parentStub,
|
||||
firstUploadCategory: orderedUploadCategories[0],
|
||||
}).finally(() => importing.current.delete(run.runId));
|
||||
}
|
||||
}, [runs, addFiles, consumeFiles, policies, fileStubs]);
|
||||
// NB: fileStubs is intentionally NOT a dependency — it's read via a ref so a
|
||||
// delivery's own workspace mutation can't re-trigger this effect (see the ref
|
||||
// declaration above). The effect fires on run completions, which is all it needs.
|
||||
}, [
|
||||
runs,
|
||||
addFiles,
|
||||
consumeFiles,
|
||||
updateStirlingFileStub,
|
||||
policies,
|
||||
orderedUploadCategories,
|
||||
]);
|
||||
|
||||
// Reconcile against the backend on load. The server owns runs (durable, user-scoped),
|
||||
// so a run started before this client recorded it, or before a refresh/crash, is
|
||||
@@ -315,7 +393,13 @@ interface ImportContext {
|
||||
inputFileIds: FileId[],
|
||||
outputs: StirlingFile[],
|
||||
stubs: StirlingFileStub[],
|
||||
options?: { silent?: boolean },
|
||||
) => Promise<unknown>;
|
||||
/** Patch a workspace stub in place (used to stamp a new-file output's category). */
|
||||
updateStirlingFileStub: (
|
||||
fileId: FileId,
|
||||
updates: Partial<StirlingFileStub>,
|
||||
) => void;
|
||||
/** Bump the IndexedDB revision so the file views re-read after a storage-only version write. */
|
||||
bumpRevision: () => void;
|
||||
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
|
||||
@@ -327,13 +411,18 @@ interface ImportContext {
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
/** The input file's stub — required to version it; absent if it's been removed. */
|
||||
parentStub: StirlingFileStub | undefined;
|
||||
/** The first upload policy in the chain — the only one the dispatch effect ever
|
||||
* fires. Every policy output is marked dispatched for it so a downstream policy's
|
||||
* output is never mistaken for a fresh upload and re-enforced (an endless loop). */
|
||||
firstUploadCategory: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the caller's server-side runs and fold them into the local store. For a run we already
|
||||
* track, patch its status/outputs (preserving local import progress + attribution); for one we
|
||||
* don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we
|
||||
* can't map to a configured category are skipped.
|
||||
* don't, adopt it for feed visibility (polled if still live, but never auto-imported — see the
|
||||
* `imported` note below). Server-excluded ad-hoc runs and runs we can't map to a configured
|
||||
* category are skipped.
|
||||
*/
|
||||
function applyOutputName(
|
||||
inputFileName: string,
|
||||
@@ -343,10 +432,21 @@ function applyOutputName(
|
||||
const dot = inputFileName.lastIndexOf(".");
|
||||
const base = dot > 0 ? inputFileName.slice(0, dot) : inputFileName;
|
||||
const ext = dot > 0 ? inputFileName.slice(dot) : "";
|
||||
if (position === "suffix") return `${base}_${outputName}${ext}`;
|
||||
if (position === "prefix") return `${outputName}_${base}${ext}`;
|
||||
// auto-number requires dedup state not available here — fall back to suffix.
|
||||
return `${base}_${outputName}${ext}`;
|
||||
// auto-number needs dedup state not available here, so it falls back to suffix.
|
||||
return position === "prefix"
|
||||
? `${outputName}_${base}${ext}`
|
||||
: `${base}_${outputName}${ext}`;
|
||||
}
|
||||
|
||||
/** The next upload policy after {@code categoryId} in the chain, or undefined if
|
||||
* it's last or no longer in the ordered set (e.g. paused since it ran). */
|
||||
function nextUploadCategory(
|
||||
orderedUploadCategories: string[],
|
||||
categoryId: string,
|
||||
): string | undefined {
|
||||
const index = orderedUploadCategories.indexOf(categoryId);
|
||||
if (index < 0) return undefined;
|
||||
return orderedUploadCategories[index + 1];
|
||||
}
|
||||
|
||||
async function reconcileServerRuns(
|
||||
@@ -371,10 +471,8 @@ async function reconcileServerRuns(
|
||||
addReconciledRun({
|
||||
runId: view.runId,
|
||||
categoryId,
|
||||
// No local input link: a run rediscovered purely from the server (never recorded by this
|
||||
// client) can't be tied back to a workspace/storage file, so its output is delivered as a
|
||||
// new file rather than a version, and it isn't retried. The recorded-run path (real fileId)
|
||||
// covers the common refresh case; this only bites true orphans (storage wipe / other device).
|
||||
// No local input link: a run rediscovered purely from the server was never recorded by
|
||||
// this client, so it can't be tied back to a workspace/storage file (and isn't retried).
|
||||
fileId: "",
|
||||
fileName: view.outputs[0]?.fileName ?? "",
|
||||
fileSize: 0,
|
||||
@@ -384,6 +482,8 @@ async function reconcileServerRuns(
|
||||
status: view.status,
|
||||
outputs: view.outputs,
|
||||
error: view.error,
|
||||
// Adopted for feed visibility ONLY, never delivery: else a completed run evicted from the capped store gets re-adopted every refresh and re-delivered as a new file (no fileId → no parent), opening phantom duplicates forever. Client-recorded runs (real fileId) still deliver.
|
||||
imported: true,
|
||||
// Use the server's creation time, not now, so a rediscovered run shows its real age.
|
||||
startedAt: view.createdAt,
|
||||
});
|
||||
@@ -473,6 +573,19 @@ async function importOutputs(
|
||||
return; // transient/mixed: retry the lot later; permanent: already failed.
|
||||
}
|
||||
|
||||
// Mark a delivered output as already-handled so the auto-run never re-enforces
|
||||
// a policy on its own output. Covers the producing policy AND the first upload
|
||||
// policy (the only one the dispatch effect fires) — without the latter, a
|
||||
// downstream policy's output looks like a fresh upload and the first policy
|
||||
// re-runs on it, versioning/duplicating endlessly. Forward chaining is
|
||||
// unaffected: it only ever fires categories AFTER the producer, never the first.
|
||||
const markHandled = (id: string) => {
|
||||
markDispatched(run.categoryId, id);
|
||||
if (ctx.firstUploadCategory && ctx.firstUploadCategory !== run.categoryId) {
|
||||
markDispatched(ctx.firstUploadCategory, id);
|
||||
}
|
||||
};
|
||||
|
||||
// Deliver, then mark exactly those imported. If delivery throws we don't mark
|
||||
// them, so they retry (without having been added).
|
||||
const files = fetched.map((f) => f.file);
|
||||
@@ -489,6 +602,21 @@ async function importOutputs(
|
||||
(await fileStorage.getStirlingFileStub(run.fileId as FileId)) ??
|
||||
undefined)
|
||||
: undefined;
|
||||
|
||||
// Resolve each output's classification labels and put them ON the stub, so
|
||||
// they ride through consume/persist to BOTH the workspace and storage — and
|
||||
// every later version inherits them (createChildStub + the CONSUME_FILES
|
||||
// reducer). This keeps files in their label groups instead of flashing into
|
||||
// "Other" and waiting on a PDF re-read when a 2nd policy or a tool runs.
|
||||
// Prefer the input's carried-forward labels (cheap) and only read the
|
||||
// freshly-labelled file when there's nothing to inherit (the classification
|
||||
// origin) — so a 60-file batch doesn't re-read every downstream output.
|
||||
const parentLabels = parentStub?.classificationLabels;
|
||||
const resolveLabels = async (file: File) =>
|
||||
(parentLabels && parentLabels.length > 0 ? parentLabels : undefined) ??
|
||||
(await readClassificationLabelsFromFile(file)) ??
|
||||
undefined;
|
||||
|
||||
if (parentStub) {
|
||||
// Replace the input file with a versioned child (preserves its history).
|
||||
// The version records "automate" as its origin tool — a policy is a
|
||||
@@ -498,25 +626,54 @@ async function importOutputs(
|
||||
parentStub,
|
||||
"automate",
|
||||
);
|
||||
// derivedFromTool is the durable cross-session guard; markDispatched is the
|
||||
// belt-and-suspenders session guard. Both are needed: dispatched lives only
|
||||
// in localStorage (wiped on clear / absent on a different device), while
|
||||
// derivedFromTool is stamped on the stub itself.
|
||||
for (const s of stubs) {
|
||||
s.derivedFromTool = true;
|
||||
markDispatched(run.categoryId, s.id);
|
||||
}
|
||||
deliveredIds = stubs.map((s) => s.id as string);
|
||||
// Transitive provenance for the PERSISTED record, mirroring what the
|
||||
// CONSUME_FILES reducer computes for workspace state: the output derives
|
||||
// from its input plus everything that input derived from. Without this the
|
||||
// stored lineage misses intermediate hops, and a closed file's policy
|
||||
// badges can't resolve past the most recent run in a 3+-policy chain.
|
||||
const lineage = Array.from(
|
||||
new Set([run.fileId as FileId, ...(parentStub.sourceFileIds ?? [])]),
|
||||
);
|
||||
// Stamp each output stub with: the resolved labels (createChildStub already
|
||||
// inherited the parent's; this also captures the classification origin,
|
||||
// where the parent had none but the labelled file does), the transitive
|
||||
// lineage, and derivedFromTool — the durable cross-session guard that stops
|
||||
// the auto-run ever re-enforcing a policy on its own output (survives a
|
||||
// localStorage wipe / a different device, unlike the dispatched markers).
|
||||
const categorized = await Promise.all(
|
||||
stubs.map(async (s, i) => {
|
||||
const labels = await resolveLabels(files[i]);
|
||||
return {
|
||||
...s,
|
||||
sourceFileIds: lineage,
|
||||
derivedFromTool: true,
|
||||
...(labels ? { classificationLabels: labels } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Mark the outputs handled BEFORE adding them (belt-and-suspenders session
|
||||
// guard on top of derivedFromTool) so the auto-run never enforces the policy
|
||||
// on its own output — that would version endlessly in a loop.
|
||||
for (const s of categorized) markHandled(s.id as string);
|
||||
deliveredIds = categorized.map((s) => s.id as string);
|
||||
if (ctx.parentStub) {
|
||||
// Input is in the active workspace: version it there (workspace + storage).
|
||||
await ctx.consumeFiles([run.fileId as FileId], stirlingFiles, stubs);
|
||||
// Input is in the active workspace: version it in place, silently — the
|
||||
// output replaces the input in the same slot without being auto-selected,
|
||||
// reordered to the top, or opened in the viewer. The category rides on the
|
||||
// stub, so it lands in the right group instantly (no re-read, no flicker).
|
||||
await ctx.consumeFiles(
|
||||
[run.fileId as FileId],
|
||||
stirlingFiles,
|
||||
categorized,
|
||||
{ silent: true },
|
||||
);
|
||||
} else {
|
||||
// Input is only in storage (run recovered after a reload): version it at the
|
||||
// storage layer, then refresh the file views.
|
||||
await fileStorage.persistVersionedOutputs(
|
||||
[run.fileId as FileId],
|
||||
stirlingFiles,
|
||||
stubs,
|
||||
categorized,
|
||||
);
|
||||
ctx.bumpRevision();
|
||||
}
|
||||
@@ -528,8 +685,28 @@ async function importOutputs(
|
||||
derivedFromTool: true,
|
||||
});
|
||||
// Belt-and-suspenders session guard on top of derivedFromTool.
|
||||
for (const f of added) markDispatched(run.categoryId, f.fileId);
|
||||
for (const f of added) markHandled(f.fileId as string);
|
||||
deliveredIds = added.map((f) => f.fileId as string);
|
||||
// Mark each new-file output as tool-derived (the versioned path gets this from the
|
||||
// CONSUME_FILES reducer; the addFiles path doesn't). This is the real loop guard: the dispatch
|
||||
// effect skips `derivedFromTool` files, so a policy output is never re-enforced as a fresh
|
||||
// upload regardless of how upload policies are later reordered — unlike per-(category,file)
|
||||
// markers keyed to whichever policy is currently first. Also stamp labels so it lands in the
|
||||
// right sidebar group immediately (a new file has no parent to inherit from).
|
||||
let mutated = false;
|
||||
await Promise.all(
|
||||
added.map(async (f, i) => {
|
||||
const labels = await resolveLabels(files[i]);
|
||||
const updates = {
|
||||
derivedFromTool: true,
|
||||
...(labels ? { classificationLabels: labels } : {}),
|
||||
};
|
||||
ctx.updateStirlingFileStub(f.fileId, updates);
|
||||
const ok = await fileStorage.updateFileMetadata(f.fileId, updates);
|
||||
if (ok) mutated = true;
|
||||
}),
|
||||
);
|
||||
if (mutated) ctx.bumpRevision();
|
||||
}
|
||||
const importedFileIds = [...done, ...fetched.map((f) => f.fileId)];
|
||||
const imported = run.outputs.every((out) =>
|
||||
|
||||
@@ -20,6 +20,9 @@ interface PolicyEnforcingOverlayProps {
|
||||
zIndex?: number;
|
||||
/** When provided, an × button is shown and called on click. */
|
||||
onDismiss?: () => void;
|
||||
/** CSS colour var of the enforcing policy's accent (e.g. `var(--color-orange)`),
|
||||
* so the icon/spinner match that policy's badge instead of a fixed blue. */
|
||||
accentVar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +34,7 @@ export function PolicyEnforcingOverlay({
|
||||
progress,
|
||||
zIndex = 200,
|
||||
onDismiss,
|
||||
accentVar,
|
||||
}: PolicyEnforcingOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!enforcing) return null;
|
||||
@@ -66,7 +70,23 @@ export function PolicyEnforcingOverlay({
|
||||
)}
|
||||
<Center style={{ height: "100%" }}>
|
||||
<Stack align="center" gap="md" w={220}>
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="blue">
|
||||
<ThemeIcon
|
||||
size={48}
|
||||
radius="xl"
|
||||
variant="light"
|
||||
color="blue"
|
||||
// Tint to the enforcing policy's accent so the overlay matches that
|
||||
// policy's badge (the classification badge is orange, security purple,
|
||||
// …); falls back to Mantine's blue light variant when no accent given.
|
||||
style={
|
||||
accentVar
|
||||
? {
|
||||
color: accentVar,
|
||||
backgroundColor: `color-mix(in srgb, ${accentVar} 14%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ShieldOutlinedIcon style={{ fontSize: 26 }} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
@@ -80,9 +100,10 @@ export function PolicyEnforcingOverlay({
|
||||
value={progress}
|
||||
striped
|
||||
animated
|
||||
color={accentVar}
|
||||
/>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
<Loader size="xs" color={accentVar} />
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -70,6 +70,7 @@ export function PolicyEnforcementOverlay({ runs }: Props) {
|
||||
zIndex={1100}
|
||||
progress={progress}
|
||||
onDismiss={() => setDismissed(true)}
|
||||
accentVar={policyAccentVar(inFlight.categoryId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
// Default classification labels. The SOURCE OF TRUTH is the co-located static
|
||||
// JSON (`classificationLabels.json`), imported here and shaped into typed
|
||||
// objects — edit THAT file, not this one. This is the ONLY copy of the label
|
||||
// data: it seeds a team's editable set and drives the sidebar's grouping,
|
||||
// icons, and display names. Neither the backend nor the engine keeps a copy.
|
||||
//
|
||||
// NOTE: this is only the built-in default vocabulary. A team's own
|
||||
// (admin-editable) labels live in the backend store and are what the backend
|
||||
// sends to the engine per classify request — the engine holds no vocabulary of
|
||||
// its own. Edits to a team's set reach Python on the next run, independent of
|
||||
// this file.
|
||||
//
|
||||
// `labels` is the flat set: each has a stable `id` (slug — the value on the wire,
|
||||
// in storage and keyed on) and a human `name` (display, translatable via
|
||||
// `classification.labels.<id>`); `icon` is presentational only, the engine never
|
||||
// sees it. `families` are presentational sidebar roll-ups (referencing labels by
|
||||
// id) the classifier never sees.
|
||||
|
||||
import labelsData from "@app/data/classificationLabels.json";
|
||||
|
||||
export interface ClassificationLabel {
|
||||
/** Stable identity (slug) — the value on the wire, stored on the doc, and
|
||||
* keyed on. Independent of the (translatable) display name. */
|
||||
id: string;
|
||||
/** Human display name; the en-US default for `classification.labels.<id>`. */
|
||||
name: string;
|
||||
/** Material Symbols icon key (see `labelIcons.ts`). */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface LabelFamily {
|
||||
/** Stable identity for sidebar prefs — never rename once shipped. */
|
||||
id: string;
|
||||
/** Group header text shown in the sidebar and the group picker. */
|
||||
name: string;
|
||||
/** Material Symbols icon key (see `labelIcons.ts`). */
|
||||
icon: string;
|
||||
/** The built-in labels this family rolls up in the sidebar. */
|
||||
labels: ClassificationLabel[];
|
||||
}
|
||||
|
||||
/** Shape of `classificationLabels.json` — the flat label set plus the
|
||||
* presentational family grouping (which references labels by id). */
|
||||
interface LabelsFile {
|
||||
labels: ClassificationLabel[];
|
||||
families: { id: string; name: string; icon: string; labelIds: string[] }[];
|
||||
}
|
||||
|
||||
const data = labelsData as LabelsFile;
|
||||
|
||||
/** Flat default label set — file order, as the classifier/team-seed sees it. */
|
||||
export const DEFAULT_CLASSIFICATION_LABELS: ClassificationLabel[] = data.labels;
|
||||
|
||||
const LABEL_BY_ID = new Map(data.labels.map((label) => [label.id, label]));
|
||||
|
||||
/**
|
||||
* Built-in families with their labels resolved from the flat set by id. Throws
|
||||
* at module load if a family references an unknown id, so a bad hand-edit of the
|
||||
* JSON fails fast rather than silently dropping a label from its group.
|
||||
*/
|
||||
export const LABEL_FAMILIES: LabelFamily[] = data.families.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
icon: family.icon,
|
||||
labels: family.labelIds.map((id) => {
|
||||
const label = LABEL_BY_ID.get(id);
|
||||
if (!label) {
|
||||
throw new Error(
|
||||
`Classification family "${family.id}" references unknown label id "${id}"`,
|
||||
);
|
||||
}
|
||||
return label;
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Stable slug id from a label's canonical (English) name — used to derive an id
|
||||
* for a NEW custom label the user types. Built-in ids are authored in the JSON;
|
||||
* this must stay in sync with the slug rule used to generate them.
|
||||
*/
|
||||
export function labelId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Proprietary override of the label-display seam: resolves a classification
|
||||
// label id to its display name, translated via `classification.labels.<id>`
|
||||
// (the built-in English name is the en-US default). Custom team labels aren't in
|
||||
// the built-in map, so they fall back to the id.
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
|
||||
|
||||
const NAME_BY_ID = new Map(
|
||||
DEFAULT_CLASSIFICATION_LABELS.map((label) => [label.id, label.name]),
|
||||
);
|
||||
|
||||
/** Returns a resolver mapping a label id to its (translated) display name. */
|
||||
export function useLabelName(): (id: string) => string {
|
||||
const { t } = useTranslation();
|
||||
return (id) => t(`classification.labels.${id}`, NAME_BY_ID.get(id) ?? id);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Curated palette of icons a classification label can use, shown in the label
|
||||
* icon picker and rendered in the file sidebar's label groups. Keys are
|
||||
* Material Symbols names rendered via {@link LocalIcon}.
|
||||
*
|
||||
* IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler
|
||||
* (`scripts/generate-icons.js`, which regex-scans for `icon: "name"`) picks every
|
||||
* one up and bundles it — otherwise a picked icon would fall back to the CDN and
|
||||
* render blank offline. After adding/removing entries run `task frontend:prepare:icons`.
|
||||
*
|
||||
* Search is intentionally omitted for now (no synonyms to maintain); the palette
|
||||
* is small enough to eyeball.
|
||||
*/
|
||||
|
||||
export interface LabelIconOption {
|
||||
/** Material Symbols key (no `material-symbols:` prefix). */
|
||||
icon: string;
|
||||
/** Short English name — the en-US default for the icon's tooltip/aria; the
|
||||
* picker translates it via `policies.labels.iconName.<icon>`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Fallback icon for a label with none set (and for the "Other" group). */
|
||||
export const DEFAULT_LABEL_ICON = "sell";
|
||||
|
||||
export const LABEL_ICON_OPTIONS: LabelIconOption[] = [
|
||||
// Money / finance
|
||||
{ icon: "receipt-long", label: "Receipt" },
|
||||
{ icon: "request-quote", label: "Quote" },
|
||||
{ icon: "payments", label: "Payments" },
|
||||
{ icon: "account-balance", label: "Bank" },
|
||||
{ icon: "savings", label: "Savings" },
|
||||
{ icon: "credit-card", label: "Card" },
|
||||
{ icon: "point-of-sale", label: "Point of sale" },
|
||||
{ icon: "sell", label: "Label" },
|
||||
{ icon: "shopping-cart", label: "Cart" },
|
||||
{ icon: "calculate", label: "Calculate" },
|
||||
// Legal / compliance
|
||||
{ icon: "handshake", label: "Agreement" },
|
||||
{ icon: "gavel", label: "Legal" },
|
||||
{ icon: "balance", label: "Balance" },
|
||||
{ icon: "policy", label: "Policy" },
|
||||
{ icon: "verified", label: "Verified" },
|
||||
{ icon: "shield", label: "Shield" },
|
||||
{ icon: "health-and-safety", label: "Safety" },
|
||||
{ icon: "lock", label: "Lock" },
|
||||
{ icon: "fact-check", label: "Fact check" },
|
||||
{ icon: "rule", label: "Rule" },
|
||||
// Documents / writing
|
||||
{ icon: "description", label: "Document" },
|
||||
{ icon: "article", label: "Article" },
|
||||
{ icon: "assignment", label: "Form" },
|
||||
{ icon: "checklist", label: "Checklist" },
|
||||
{ icon: "task", label: "Task" },
|
||||
{ icon: "summarize", label: "Summary" },
|
||||
{ icon: "sticky-note-2", label: "Note" },
|
||||
{ icon: "edit-document", label: "Edit" },
|
||||
{ icon: "draft", label: "Draft" },
|
||||
{ icon: "folder", label: "Folder" },
|
||||
// People / comms
|
||||
{ icon: "mail", label: "Mail" },
|
||||
{ icon: "campaign", label: "Campaign" },
|
||||
{ icon: "contact-page", label: "Contact" },
|
||||
{ icon: "badge", label: "Badge" },
|
||||
{ icon: "groups", label: "Group" },
|
||||
{ icon: "person", label: "Person" },
|
||||
{ icon: "work", label: "Work" },
|
||||
{ icon: "business-center", label: "Business" },
|
||||
{ icon: "event", label: "Event" },
|
||||
{ icon: "calendar-month", label: "Calendar" },
|
||||
// Charts / education / logistics / misc
|
||||
{ icon: "bar-chart", label: "Bar chart" },
|
||||
{ icon: "monitoring", label: "Monitoring" },
|
||||
{ icon: "pie-chart", label: "Pie chart" },
|
||||
{ icon: "trending-up", label: "Trend" },
|
||||
{ icon: "table-chart", label: "Table" },
|
||||
{ icon: "school", label: "School" },
|
||||
{ icon: "menu-book", label: "Book" },
|
||||
{ icon: "science", label: "Science" },
|
||||
{ icon: "medical-services", label: "Medical" },
|
||||
{ icon: "local-shipping", label: "Shipping" },
|
||||
{ icon: "slideshow", label: "Presentation" },
|
||||
{ icon: "home-work", label: "Property" },
|
||||
{ icon: "real-estate-agent", label: "Real estate" },
|
||||
{ icon: "engineering", label: "Technical" },
|
||||
{ icon: "inventory-2", label: "Inventory" },
|
||||
{ icon: "image", label: "Image" },
|
||||
// Money / finance (extended)
|
||||
{ icon: "paid", label: "Paid" },
|
||||
{ icon: "attach-money", label: "Money" },
|
||||
{ icon: "currency-exchange", label: "Exchange" },
|
||||
{ icon: "account-balance-wallet", label: "Wallet" },
|
||||
{ icon: "price-check", label: "Price check" },
|
||||
{ icon: "wallet", label: "Billfold" },
|
||||
{ icon: "redeem", label: "Redeem" },
|
||||
{ icon: "receipt", label: "Receipt (short)" },
|
||||
{ icon: "percent", label: "Percent" },
|
||||
// Legal / security (extended)
|
||||
{ icon: "encrypted", label: "Encrypted" },
|
||||
{ icon: "privacy-tip", label: "Privacy" },
|
||||
{ icon: "security", label: "Security" },
|
||||
{ icon: "assured-workload", label: "Assured" },
|
||||
{ icon: "copyright", label: "Copyright" },
|
||||
{ icon: "approval", label: "Approval" },
|
||||
{ icon: "history-edu", label: "Quill" },
|
||||
// Documents (extended)
|
||||
{ icon: "topic", label: "Topic" },
|
||||
{ icon: "note-add", label: "New note" },
|
||||
{ icon: "text-snippet", label: "Snippet" },
|
||||
{ icon: "library-books", label: "Library" },
|
||||
{ icon: "auto-stories", label: "Open book" },
|
||||
{ icon: "newspaper", label: "Newspaper" },
|
||||
{ icon: "folder-open", label: "Open folder" },
|
||||
{ icon: "format-list-bulleted", label: "List" },
|
||||
// People / HR (extended)
|
||||
{ icon: "manage-accounts", label: "Manage accounts" },
|
||||
{ icon: "supervisor-account", label: "Supervisor" },
|
||||
{ icon: "how-to-reg", label: "Registered" },
|
||||
{ icon: "diversity-3", label: "Team" },
|
||||
{ icon: "psychology", label: "Psychology" },
|
||||
{ icon: "volunteer-activism", label: "Volunteer" },
|
||||
// Health (extended)
|
||||
{ icon: "medical-information", label: "Medical info" },
|
||||
{ icon: "medication", label: "Medication" },
|
||||
{ icon: "vaccines", label: "Vaccine" },
|
||||
{ icon: "monitor-heart", label: "Heart monitor" },
|
||||
{ icon: "emergency", label: "Emergency" },
|
||||
{ icon: "stethoscope", label: "Stethoscope" },
|
||||
// Property / places (extended)
|
||||
{ icon: "home", label: "Home" },
|
||||
{ icon: "apartment", label: "Apartment" },
|
||||
{ icon: "storefront", label: "Storefront" },
|
||||
{ icon: "location-on", label: "Location" },
|
||||
{ icon: "map", label: "Map" },
|
||||
{ icon: "public", label: "Globe" },
|
||||
// Logistics (extended)
|
||||
{ icon: "package-2", label: "Package" },
|
||||
{ icon: "warehouse", label: "Warehouse" },
|
||||
{ icon: "forklift", label: "Forklift" },
|
||||
{ icon: "pallet", label: "Pallet" },
|
||||
{ icon: "flight", label: "Flight" },
|
||||
{ icon: "luggage", label: "Luggage" },
|
||||
// Technology / engineering (extended)
|
||||
{ icon: "code", label: "Code" },
|
||||
{ icon: "terminal", label: "Terminal" },
|
||||
{ icon: "database", label: "Database" },
|
||||
{ icon: "cloud", label: "Cloud" },
|
||||
{ icon: "api", label: "API" },
|
||||
{ icon: "bug-report", label: "Bug" },
|
||||
{ icon: "build", label: "Wrench" },
|
||||
{ icon: "architecture", label: "Compass" },
|
||||
{ icon: "design-services", label: "Design" },
|
||||
{ icon: "precision-manufacturing", label: "Robotics" },
|
||||
{ icon: "factory", label: "Factory" },
|
||||
{ icon: "bolt", label: "Bolt" },
|
||||
{ icon: "construction", label: "Construction" },
|
||||
// Charts / time (extended)
|
||||
{ icon: "analytics", label: "Analytics" },
|
||||
{ icon: "insights", label: "Insights" },
|
||||
{ icon: "query-stats", label: "Stats" },
|
||||
{ icon: "leaderboard", label: "Leaderboard" },
|
||||
{ icon: "functions", label: "Functions" },
|
||||
{ icon: "schedule", label: "Clock" },
|
||||
{ icon: "pending-actions", label: "Pending" },
|
||||
{ icon: "timeline", label: "Timeline" },
|
||||
// Communication (extended)
|
||||
{ icon: "chat", label: "Chat" },
|
||||
{ icon: "forum", label: "Forum" },
|
||||
{ icon: "call", label: "Call" },
|
||||
{ icon: "send", label: "Send" },
|
||||
{ icon: "inbox", label: "Inbox" },
|
||||
{ icon: "alternate-email", label: "At sign" },
|
||||
// Events / travel (extended)
|
||||
{ icon: "airplane-ticket", label: "Plane ticket" },
|
||||
{ icon: "confirmation-number", label: "Ticket stub" },
|
||||
{ icon: "hotel", label: "Hotel" },
|
||||
{ icon: "restaurant", label: "Restaurant" },
|
||||
{ icon: "celebration", label: "Celebration" },
|
||||
// Media / creative (extended)
|
||||
{ icon: "photo-camera", label: "Camera" },
|
||||
{ icon: "videocam", label: "Video" },
|
||||
{ icon: "music-note", label: "Music" },
|
||||
{ icon: "palette", label: "Palette" },
|
||||
{ icon: "brush", label: "Brush" },
|
||||
{ icon: "movie", label: "Movie" },
|
||||
// Nature / misc (extended)
|
||||
{ icon: "eco", label: "Eco" },
|
||||
{ icon: "recycling", label: "Recycling" },
|
||||
{ icon: "agriculture", label: "Agriculture" },
|
||||
];
|
||||
|
||||
/** Set of valid palette keys, for validating a stored/imported icon. */
|
||||
export const LABEL_ICON_KEYS: ReadonlySet<string> = new Set(
|
||||
LABEL_ICON_OPTIONS.map((option) => option.icon),
|
||||
);
|
||||
@@ -16,6 +16,7 @@ import PublicIcon from "@mui/icons-material/Public";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
@@ -42,6 +43,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
icon: <ShieldIcon sx={ICON_SX} />,
|
||||
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "Classification",
|
||||
icon: <LabelOutlinedIcon sx={ICON_SX} />,
|
||||
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
|
||||
// Needs the AI engine to classify; hidden from the policy list when it's off.
|
||||
requiresAiEngine: true,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
@@ -204,6 +213,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
// output naming + retries are set in the wizard.
|
||||
fields: [],
|
||||
},
|
||||
classification: {
|
||||
summary:
|
||||
"Classifies every uploaded document and writes the result to its metadata.",
|
||||
rules: ["Classify", "Tag metadata"],
|
||||
// Single backend step: classify the document via the AI engine and store the
|
||||
// result in the document's StirlingPDFClassification metadata field.
|
||||
defaultOperations: [{ operation: "classify", parameters: {} }],
|
||||
scopeLabel: "All PDFs on this device",
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary:
|
||||
"Validates documents against regulatory frameworks before they leave the system.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user