Compare commits
5 Commits
992f897878
...
3b72919015
| Author | SHA1 | Date | |
|---|---|---|---|
|
3b72919015
|
|||
|
859acc133c
|
|||
|
df401b9e55
|
|||
|
a18545fb18
|
|||
|
4beab4bc09
|
20
CHANGELOG.md
Normal file
20
CHANGELOG.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initialized `CHANGELOG.md` with Keep a Changelog structure for ongoing release-note tracking.
|
||||
|
||||
### Changed
|
||||
- Refreshed `README.md` with current stack details, runtime services, setup commands, configuration notes, and manual validation guidance.
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
### Security
|
||||
145
README.md
145
README.md
@@ -1,121 +1,154 @@
|
||||
# DMS
|
||||
|
||||
DMS is a single-user document management system for ingesting, processing, organizing, and searching files.
|
||||
DMS is a self-hosted document management system for ingesting, processing, organizing, and searching files.
|
||||
|
||||
Core capabilities:
|
||||
- drag-and-drop upload from anywhere in the UI
|
||||
- file and folder upload with path preservation
|
||||
- asynchronous processing with OCR and extraction for PDF, images, DOCX, XLSX, TXT, and ZIP
|
||||
- metadata and full-text search
|
||||
- routing suggestions based on learned decisions
|
||||
- original file download and extracted markdown export
|
||||
## Core Capabilities
|
||||
|
||||
## Stack
|
||||
- Drag and drop upload from anywhere in the UI
|
||||
- File and folder upload with path preservation
|
||||
- Asynchronous extraction and OCR for PDF, images, DOCX, XLSX, TXT, and ZIP
|
||||
- Metadata and full-text search
|
||||
- Routing suggestions based on previous decisions
|
||||
- Original file download and extracted markdown export
|
||||
|
||||
- Backend: FastAPI + SQLAlchemy + RQ worker (`backend/`)
|
||||
- Frontend: React + Vite + TypeScript (`frontend/`)
|
||||
- Infrastructure: Postgres, Redis, Typesense (`docker-compose.yml`)
|
||||
## Technology Stack
|
||||
|
||||
- Backend: FastAPI, SQLAlchemy, RQ worker (`backend/`)
|
||||
- Frontend: React, Vite, TypeScript (`frontend/`)
|
||||
- Infrastructure: PostgreSQL, Redis, Typesense (`docker-compose.yml`)
|
||||
|
||||
## Runtime Services
|
||||
|
||||
The default `docker compose` stack includes:
|
||||
|
||||
- `frontend` - React UI (`http://localhost:5173`)
|
||||
- `api` - FastAPI backend (`http://localhost:8000`, docs at `/docs`)
|
||||
- `worker` - background processing jobs
|
||||
- `db` - PostgreSQL (`localhost:5432`)
|
||||
- `redis` - queue backend (`localhost:6379`)
|
||||
- `typesense` - search index (`localhost:8108`)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose plugin
|
||||
- Internet access for the first image build
|
||||
- Internet access for first-time image build
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Start the full stack from repository root:
|
||||
From repository root:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
2. Open services:
|
||||
Open:
|
||||
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend OpenAPI docs: `http://localhost:8000/docs`
|
||||
- Health endpoint: `http://localhost:8000/api/v1/health`
|
||||
- API docs: `http://localhost:8000/docs`
|
||||
- Health: `http://localhost:8000/api/v1/health`
|
||||
|
||||
3. Stop when done:
|
||||
Stop the stack:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
## Common Operations
|
||||
|
||||
Start services:
|
||||
Start or rebuild:
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Stop services:
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Stream logs:
|
||||
Tail logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Rebuild services:
|
||||
Tail API and worker logs only:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up --build -d
|
||||
docker compose logs -f api worker
|
||||
```
|
||||
|
||||
Reset runtime data (destructive, removes named volumes):
|
||||
Reset all runtime data (destructive):
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Frontend-Only Local Workflow
|
||||
|
||||
If backend services are already running, you can run frontend tooling locally:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
cd frontend && npm run build
|
||||
cd frontend && npm run preview
|
||||
```
|
||||
|
||||
`npm run preview` serves the built app on port `4173`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Main runtime variables are defined in `docker-compose.yml`:
|
||||
|
||||
- API and worker: `DATABASE_URL`, `REDIS_URL`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `TYPESENSE_*`
|
||||
- Frontend: `VITE_API_BASE`
|
||||
|
||||
Application settings saved from the UI persist at:
|
||||
|
||||
- `<STORAGE_ROOT>/settings.json` (inside the storage volume)
|
||||
|
||||
Settings endpoints:
|
||||
|
||||
- `GET/PUT /api/v1/settings`
|
||||
- `POST /api/v1/settings/reset`
|
||||
- `POST /api/v1/settings/handwriting`
|
||||
- `POST /api/v1/processing/logs/trim`
|
||||
|
||||
Note: the compose file currently includes host-specific URL values (for example `PUBLIC_BASE_URL` and `VITE_API_BASE`). Adjust these for your environment when needed.
|
||||
|
||||
## Data Persistence
|
||||
|
||||
Runtime state is persisted in Docker named volumes declared in `docker-compose.yml`:
|
||||
Docker named volumes used by the stack:
|
||||
|
||||
- `db-data`
|
||||
- `redis-data`
|
||||
- `dcm-storage`
|
||||
- `typesense-data`
|
||||
|
||||
The application settings file is stored under the storage volume at `/data/storage/settings.json` inside containers.
|
||||
## Validation Checklist
|
||||
|
||||
## Configuration Notes
|
||||
After setup or config changes, verify:
|
||||
|
||||
- API and worker runtime environment values are configured in `docker-compose.yml` (`DATABASE_URL`, `REDIS_URL`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `TYPESENSE_*`).
|
||||
- Frontend API target is controlled by `VITE_API_BASE` in the `frontend` service.
|
||||
- Handwriting, provider, routing, summary, display, and upload defaults are managed through the settings UI and persisted by the backend settings service.
|
||||
|
||||
## Manual Validation Checklist
|
||||
|
||||
After changes, verify:
|
||||
- `GET /api/v1/health` returns `{"status":"ok"}`
|
||||
- upload and processing complete successfully
|
||||
- search returns expected results
|
||||
- preview or download works for uploaded documents
|
||||
- `docker compose logs -f` shows no API or worker failures
|
||||
- Upload and processing complete successfully
|
||||
- Search returns expected results
|
||||
- Preview and download work for uploaded documents
|
||||
- `docker compose logs -f api worker` has no failures
|
||||
|
||||
## API Surface Summary
|
||||
## Repository Layout
|
||||
|
||||
Base prefix: `/api/v1`
|
||||
- `backend/` - FastAPI API, services, models, worker
|
||||
- `frontend/` - React application
|
||||
- `doc/` - technical documentation for architecture, API, data model, and operations
|
||||
- `docker-compose.yml` - local runtime topology
|
||||
|
||||
- Health: `/health`
|
||||
- Documents: `/documents` (listing, upload, metadata update, lifecycle actions, download and preview, markdown export)
|
||||
- Search: `/search`
|
||||
- Processing logs: `/processing/logs`
|
||||
- Settings: `/settings` and `/settings/handwriting`
|
||||
## Documentation Index
|
||||
|
||||
See `doc/api-contract.md` for the complete endpoint contract.
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- `doc/README.md` - technical documentation index
|
||||
- `doc/README.md` - technical documentation entrypoint
|
||||
- `doc/architecture-overview.md` - service and runtime architecture
|
||||
- `doc/api-contract.md` - HTTP endpoint contract and payload model map
|
||||
- `doc/data-model-reference.md` - database model reference
|
||||
- `doc/operations-and-configuration.md` - operations runbook and configuration reference
|
||||
- `doc/frontend-design-foundation.md` - frontend design system and UI rules
|
||||
- `doc/api-contract.md` - endpoint and payload contract
|
||||
- `doc/data-model-reference.md` - persistence model reference
|
||||
- `doc/operations-and-configuration.md` - runtime operations and configuration
|
||||
- `doc/frontend-design-foundation.md` - frontend design rules
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import get_session
|
||||
from app.schemas.processing_logs import ProcessingLogEntryResponse, ProcessingLogListResponse
|
||||
from app.services.app_settings import read_processing_log_retention_settings
|
||||
from app.services.processing_logs import (
|
||||
cleanup_processing_logs,
|
||||
clear_processing_logs,
|
||||
@@ -42,16 +43,28 @@ def get_processing_logs(
|
||||
|
||||
@router.post("/trim")
|
||||
def trim_processing_logs(
|
||||
keep_document_sessions: int = Query(default=2, ge=0, le=20),
|
||||
keep_unbound_entries: int = Query(default=80, ge=0, le=400),
|
||||
keep_document_sessions: int | None = Query(default=None, ge=0, le=20),
|
||||
keep_unbound_entries: int | None = Query(default=None, ge=0, le=400),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
"""Deletes old processing logs while keeping recent document sessions and unbound events."""
|
||||
"""Deletes old processing logs using query values or persisted retention defaults."""
|
||||
|
||||
retention_defaults = read_processing_log_retention_settings()
|
||||
resolved_keep_document_sessions = (
|
||||
keep_document_sessions
|
||||
if keep_document_sessions is not None
|
||||
else int(retention_defaults.get("keep_document_sessions", 2))
|
||||
)
|
||||
resolved_keep_unbound_entries = (
|
||||
keep_unbound_entries
|
||||
if keep_unbound_entries is not None
|
||||
else int(retention_defaults.get("keep_unbound_entries", 80))
|
||||
)
|
||||
|
||||
result = cleanup_processing_logs(
|
||||
session=session,
|
||||
keep_document_sessions=keep_document_sessions,
|
||||
keep_unbound_entries=keep_unbound_entries,
|
||||
keep_document_sessions=resolved_keep_document_sessions,
|
||||
keep_unbound_entries=resolved_keep_unbound_entries,
|
||||
)
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.schemas.settings import (
|
||||
HandwritingStyleSettingsResponse,
|
||||
HandwritingSettingsUpdateRequest,
|
||||
OcrTaskSettingsResponse,
|
||||
ProcessingLogRetentionSettingsResponse,
|
||||
ProviderSettingsResponse,
|
||||
RoutingTaskSettingsResponse,
|
||||
SummaryTaskSettingsResponse,
|
||||
@@ -35,6 +36,7 @@ def _build_response(payload: dict) -> AppSettingsResponse:
|
||||
|
||||
upload_defaults_payload = payload.get("upload_defaults", {})
|
||||
display_payload = payload.get("display", {})
|
||||
processing_log_retention_payload = payload.get("processing_log_retention", {})
|
||||
providers_payload = payload.get("providers", [])
|
||||
tasks_payload = payload.get("tasks", {})
|
||||
handwriting_style_payload = payload.get("handwriting_style_clustering", {})
|
||||
@@ -55,6 +57,10 @@ def _build_response(payload: dict) -> AppSettingsResponse:
|
||||
cards_per_page=int(display_payload.get("cards_per_page", 12)),
|
||||
log_typing_animation_enabled=bool(display_payload.get("log_typing_animation_enabled", True)),
|
||||
),
|
||||
processing_log_retention=ProcessingLogRetentionSettingsResponse(
|
||||
keep_document_sessions=int(processing_log_retention_payload.get("keep_document_sessions", 2)),
|
||||
keep_unbound_entries=int(processing_log_retention_payload.get("keep_unbound_entries", 80)),
|
||||
),
|
||||
handwriting_style_clustering=HandwritingStyleSettingsResponse(
|
||||
enabled=bool(handwriting_style_payload.get("enabled", True)),
|
||||
embed_model=str(handwriting_style_payload.get("embed_model", "ts/clip-vit-b-p32")),
|
||||
@@ -159,6 +165,10 @@ def set_app_settings(payload: AppSettingsUpdateRequest) -> AppSettingsResponse:
|
||||
if payload.display is not None:
|
||||
display_payload = payload.display.model_dump(exclude_none=True)
|
||||
|
||||
processing_log_retention_payload = None
|
||||
if payload.processing_log_retention is not None:
|
||||
processing_log_retention_payload = payload.processing_log_retention.model_dump(exclude_none=True)
|
||||
|
||||
handwriting_style_payload = None
|
||||
if payload.handwriting_style_clustering is not None:
|
||||
handwriting_style_payload = payload.handwriting_style_clustering.model_dump(exclude_none=True)
|
||||
@@ -174,6 +184,7 @@ def set_app_settings(payload: AppSettingsUpdateRequest) -> AppSettingsResponse:
|
||||
tasks=tasks_payload,
|
||||
upload_defaults=upload_defaults_payload,
|
||||
display=display_payload,
|
||||
processing_log_retention=processing_log_retention_payload,
|
||||
handwriting_style=handwriting_style_payload,
|
||||
predefined_paths=predefined_paths_payload,
|
||||
predefined_tags=predefined_tags_payload,
|
||||
|
||||
@@ -127,6 +127,20 @@ class DisplaySettingsUpdateRequest(BaseModel):
|
||||
log_typing_animation_enabled: bool | None = None
|
||||
|
||||
|
||||
class ProcessingLogRetentionSettingsResponse(BaseModel):
|
||||
"""Represents retention limits used when pruning processing pipeline logs."""
|
||||
|
||||
keep_document_sessions: int = Field(default=2, ge=0, le=20)
|
||||
keep_unbound_entries: int = Field(default=80, ge=0, le=400)
|
||||
|
||||
|
||||
class ProcessingLogRetentionSettingsUpdateRequest(BaseModel):
|
||||
"""Represents partial updates for processing log retention limits."""
|
||||
|
||||
keep_document_sessions: int | None = Field(default=None, ge=0, le=20)
|
||||
keep_unbound_entries: int | None = Field(default=None, ge=0, le=400)
|
||||
|
||||
|
||||
class PredefinedPathEntryResponse(BaseModel):
|
||||
"""Represents one predefined logical path with global discoverability scope."""
|
||||
|
||||
@@ -200,6 +214,7 @@ class AppSettingsResponse(BaseModel):
|
||||
|
||||
upload_defaults: UploadDefaultsResponse
|
||||
display: DisplaySettingsResponse
|
||||
processing_log_retention: ProcessingLogRetentionSettingsResponse
|
||||
handwriting_style_clustering: HandwritingStyleSettingsResponse
|
||||
predefined_paths: list[PredefinedPathEntryResponse] = Field(default_factory=list)
|
||||
predefined_tags: list[PredefinedTagEntryResponse] = Field(default_factory=list)
|
||||
@@ -212,6 +227,7 @@ class AppSettingsUpdateRequest(BaseModel):
|
||||
|
||||
upload_defaults: UploadDefaultsUpdateRequest | None = None
|
||||
display: DisplaySettingsUpdateRequest | None = None
|
||||
processing_log_retention: ProcessingLogRetentionSettingsUpdateRequest | None = None
|
||||
handwriting_style_clustering: HandwritingStyleSettingsUpdateRequest | None = None
|
||||
predefined_paths: list[PredefinedPathEntryUpdateRequest] | None = None
|
||||
predefined_tags: list[PredefinedTagEntryUpdateRequest] | None = None
|
||||
|
||||
@@ -15,6 +15,7 @@ TASK_OCR_HANDWRITING = "ocr_handwriting"
|
||||
TASK_SUMMARY_GENERATION = "summary_generation"
|
||||
TASK_ROUTING_CLASSIFICATION = "routing_classification"
|
||||
HANDWRITING_STYLE_SETTINGS_KEY = "handwriting_style_clustering"
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY = "processing_log_retention"
|
||||
PREDEFINED_PATHS_SETTINGS_KEY = "predefined_paths"
|
||||
PREDEFINED_TAGS_SETTINGS_KEY = "predefined_tags"
|
||||
DEFAULT_HANDWRITING_STYLE_EMBED_MODEL = "ts/clip-vit-b-p32"
|
||||
@@ -65,6 +66,10 @@ def _default_settings() -> dict[str, Any]:
|
||||
"cards_per_page": 12,
|
||||
"log_typing_animation_enabled": True,
|
||||
},
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY: {
|
||||
"keep_document_sessions": 2,
|
||||
"keep_unbound_entries": 80,
|
||||
},
|
||||
PREDEFINED_PATHS_SETTINGS_KEY: [],
|
||||
PREDEFINED_TAGS_SETTINGS_KEY: [],
|
||||
HANDWRITING_STYLE_SETTINGS_KEY: {
|
||||
@@ -148,6 +153,18 @@ def _clamp_cards_per_page(value: int) -> int:
|
||||
return max(1, min(200, value))
|
||||
|
||||
|
||||
def _clamp_processing_log_document_sessions(value: int) -> int:
|
||||
"""Clamps the number of recent document log sessions kept during cleanup."""
|
||||
|
||||
return max(0, min(20, value))
|
||||
|
||||
|
||||
def _clamp_processing_log_unbound_entries(value: int) -> int:
|
||||
"""Clamps retained unbound processing log events kept during cleanup."""
|
||||
|
||||
return max(0, min(400, value))
|
||||
|
||||
|
||||
def _clamp_predefined_entries_limit(value: int) -> int:
|
||||
"""Clamps maximum count for predefined tag/path catalog entries."""
|
||||
|
||||
@@ -401,6 +418,28 @@ def _normalize_display_settings(payload: dict[str, Any], defaults: dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
def _normalize_processing_log_retention(payload: dict[str, Any], defaults: dict[str, Any]) -> dict[str, int]:
|
||||
"""Normalizes processing log retention settings used by API and worker cleanup defaults."""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
default_keep_document_sessions = _clamp_processing_log_document_sessions(
|
||||
_safe_int(defaults.get("keep_document_sessions", 2), 2)
|
||||
)
|
||||
default_keep_unbound_entries = _clamp_processing_log_unbound_entries(
|
||||
_safe_int(defaults.get("keep_unbound_entries", 80), 80)
|
||||
)
|
||||
return {
|
||||
"keep_document_sessions": _clamp_processing_log_document_sessions(
|
||||
_safe_int(payload.get("keep_document_sessions", default_keep_document_sessions), default_keep_document_sessions)
|
||||
),
|
||||
"keep_unbound_entries": _clamp_processing_log_unbound_entries(
|
||||
_safe_int(payload.get("keep_unbound_entries", default_keep_unbound_entries), default_keep_unbound_entries)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_predefined_paths(
|
||||
payload: Any,
|
||||
existing_items: list[dict[str, Any]] | None = None,
|
||||
@@ -567,6 +606,10 @@ def _sanitize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized_tasks = _normalize_tasks(tasks_payload, provider_ids)
|
||||
upload_defaults = _normalize_upload_defaults(payload.get("upload_defaults", {}), defaults["upload_defaults"])
|
||||
display_settings = _normalize_display_settings(payload.get("display", {}), defaults["display"])
|
||||
processing_log_retention = _normalize_processing_log_retention(
|
||||
payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}),
|
||||
defaults[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||
)
|
||||
predefined_paths = _normalize_predefined_paths(
|
||||
payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||
existing_items=payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||
@@ -583,6 +626,7 @@ def _sanitize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"upload_defaults": upload_defaults,
|
||||
"display": display_settings,
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY: processing_log_retention,
|
||||
PREDEFINED_PATHS_SETTINGS_KEY: predefined_paths,
|
||||
PREDEFINED_TAGS_SETTINGS_KEY: predefined_tags,
|
||||
HANDWRITING_STYLE_SETTINGS_KEY: handwriting_style_settings,
|
||||
@@ -645,6 +689,10 @@ def read_app_settings() -> dict[str, Any]:
|
||||
return {
|
||||
"upload_defaults": payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []}),
|
||||
"display": payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True}),
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY: payload.get(
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY,
|
||||
_default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||
),
|
||||
PREDEFINED_PATHS_SETTINGS_KEY: payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||
PREDEFINED_TAGS_SETTINGS_KEY: payload.get(PREDEFINED_TAGS_SETTINGS_KEY, []),
|
||||
HANDWRITING_STYLE_SETTINGS_KEY: payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}),
|
||||
@@ -687,16 +735,23 @@ def update_app_settings(
|
||||
tasks: dict[str, dict[str, Any]] | None = None,
|
||||
upload_defaults: dict[str, Any] | None = None,
|
||||
display: dict[str, Any] | None = None,
|
||||
processing_log_retention: dict[str, Any] | None = None,
|
||||
handwriting_style: dict[str, Any] | None = None,
|
||||
predefined_paths: list[dict[str, Any]] | None = None,
|
||||
predefined_tags: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Updates app settings, persists them, and returns API-safe values."""
|
||||
"""Updates app settings blocks, persists them, and returns API-safe values."""
|
||||
|
||||
current_payload = _read_raw_settings()
|
||||
next_payload: dict[str, Any] = {
|
||||
"upload_defaults": dict(current_payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []})),
|
||||
"display": dict(current_payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True})),
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY: dict(
|
||||
current_payload.get(
|
||||
PROCESSING_LOG_RETENTION_SETTINGS_KEY,
|
||||
_default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||
)
|
||||
),
|
||||
PREDEFINED_PATHS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_PATHS_SETTINGS_KEY, [])),
|
||||
PREDEFINED_TAGS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_TAGS_SETTINGS_KEY, [])),
|
||||
HANDWRITING_STYLE_SETTINGS_KEY: dict(
|
||||
@@ -766,6 +821,13 @@ def update_app_settings(
|
||||
next_display["log_typing_animation_enabled"] = bool(display["log_typing_animation_enabled"])
|
||||
next_payload["display"] = next_display
|
||||
|
||||
if processing_log_retention is not None and isinstance(processing_log_retention, dict):
|
||||
next_retention = dict(next_payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}))
|
||||
for key in ("keep_document_sessions", "keep_unbound_entries"):
|
||||
if key in processing_log_retention:
|
||||
next_retention[key] = processing_log_retention[key]
|
||||
next_payload[PROCESSING_LOG_RETENTION_SETTINGS_KEY] = next_retention
|
||||
|
||||
if handwriting_style is not None and isinstance(handwriting_style, dict):
|
||||
next_handwriting_style = dict(next_payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}))
|
||||
for key in (
|
||||
@@ -828,6 +890,17 @@ def read_handwriting_style_settings() -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def read_processing_log_retention_settings() -> dict[str, int]:
|
||||
"""Returns normalized processing log retention defaults used by worker and trim APIs."""
|
||||
|
||||
payload = _read_raw_settings()
|
||||
defaults = _default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY]
|
||||
return _normalize_processing_log_retention(
|
||||
payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}),
|
||||
defaults,
|
||||
)
|
||||
|
||||
|
||||
def read_predefined_paths_settings() -> list[dict[str, Any]]:
|
||||
"""Returns normalized predefined logical path catalog entries."""
|
||||
|
||||
|
||||
@@ -5,10 +5,15 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.models.document import Document, DocumentStatus
|
||||
from app.services.app_settings import read_handwriting_provider_settings, read_handwriting_style_settings
|
||||
from app.services.app_settings import (
|
||||
read_handwriting_provider_settings,
|
||||
read_handwriting_style_settings,
|
||||
read_processing_log_retention_settings,
|
||||
)
|
||||
from app.services.extractor import (
|
||||
IMAGE_EXTENSIONS,
|
||||
extract_archive_members,
|
||||
@@ -32,6 +37,17 @@ from app.services.storage import absolute_path, compute_sha256, store_bytes, wri
|
||||
from app.worker.queue import get_processing_queue
|
||||
|
||||
|
||||
def _cleanup_processing_logs_with_settings(session: Session) -> None:
|
||||
"""Applies configured processing log retention while trimming old log entries."""
|
||||
|
||||
retention = read_processing_log_retention_settings()
|
||||
cleanup_processing_logs(
|
||||
session=session,
|
||||
keep_document_sessions=int(retention.get("keep_document_sessions", 2)),
|
||||
keep_unbound_entries=int(retention.get("keep_unbound_entries", 80)),
|
||||
)
|
||||
|
||||
|
||||
def _create_archive_member_document(
|
||||
parent: Document,
|
||||
member_name: str,
|
||||
@@ -204,7 +220,7 @@ def process_document_task(document_id: str) -> None:
|
||||
document=document,
|
||||
payload_json={"status": document.status.value},
|
||||
)
|
||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
||||
_cleanup_processing_logs_with_settings(session=session)
|
||||
session.commit()
|
||||
for child_id in child_ids:
|
||||
queue.enqueue("app.worker.tasks.process_document_task", child_id)
|
||||
@@ -239,7 +255,7 @@ def process_document_task(document_id: str) -> None:
|
||||
document=document,
|
||||
payload_json={"status": document.status.value},
|
||||
)
|
||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
||||
_cleanup_processing_logs_with_settings(session=session)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
@@ -330,7 +346,7 @@ def process_document_task(document_id: str) -> None:
|
||||
document=document,
|
||||
payload_json={"status": document.status.value},
|
||||
)
|
||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
||||
_cleanup_processing_logs_with_settings(session=session)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
@@ -362,7 +378,7 @@ def process_document_task(document_id: str) -> None:
|
||||
document=document,
|
||||
payload_json={"status": document.status.value},
|
||||
)
|
||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
||||
_cleanup_processing_logs_with_settings(session=session)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
@@ -540,5 +556,5 @@ def process_document_task(document_id: str) -> None:
|
||||
document=document,
|
||||
payload_json={"status": document.status.value},
|
||||
)
|
||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
||||
_cleanup_processing_logs_with_settings(session=session)
|
||||
session.commit()
|
||||
|
||||
135
backend/tests/test_processing_log_retention_settings.py
Normal file
135
backend/tests/test_processing_log_retention_settings.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Unit coverage for persisted processing log retention settings behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
if "pydantic_settings" not in sys.modules:
|
||||
pydantic_settings_stub = ModuleType("pydantic_settings")
|
||||
|
||||
class _BaseSettings:
|
||||
"""Minimal BaseSettings replacement for dependency-light unit test execution."""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def _settings_config_dict(**kwargs: object) -> dict[str, object]:
|
||||
"""Returns configuration values using dict semantics expected by settings module."""
|
||||
|
||||
return kwargs
|
||||
|
||||
pydantic_settings_stub.BaseSettings = _BaseSettings
|
||||
pydantic_settings_stub.SettingsConfigDict = _settings_config_dict
|
||||
sys.modules["pydantic_settings"] = pydantic_settings_stub
|
||||
|
||||
from app.schemas.settings import AppSettingsUpdateRequest, ProcessingLogRetentionSettingsUpdateRequest
|
||||
from app.services import app_settings
|
||||
|
||||
|
||||
def _sample_current_payload() -> dict:
|
||||
"""Builds a sanitized payload used as in-memory persistence fixture for update tests."""
|
||||
|
||||
return app_settings._sanitize_settings(app_settings._default_settings())
|
||||
|
||||
|
||||
class ProcessingLogRetentionSettingsTests(unittest.TestCase):
|
||||
"""Verifies defaulting, sanitization, schema mapping, and update merge behavior."""
|
||||
|
||||
def test_sanitize_settings_uses_default_retention_values(self) -> None:
|
||||
"""Defaults are restored when persisted payload omits retention settings."""
|
||||
|
||||
sanitized = app_settings._sanitize_settings({})
|
||||
self.assertEqual(
|
||||
sanitized["processing_log_retention"],
|
||||
{
|
||||
"keep_document_sessions": 2,
|
||||
"keep_unbound_entries": 80,
|
||||
},
|
||||
)
|
||||
|
||||
def test_sanitize_settings_clamps_retention_values(self) -> None:
|
||||
"""Retention values are clamped to same bounds enforced by trim endpoint query rules."""
|
||||
|
||||
sanitized = app_settings._sanitize_settings(
|
||||
{
|
||||
"processing_log_retention": {
|
||||
"keep_document_sessions": 99,
|
||||
"keep_unbound_entries": -5,
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
sanitized["processing_log_retention"],
|
||||
{
|
||||
"keep_document_sessions": 20,
|
||||
"keep_unbound_entries": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_update_request_schema_accepts_processing_log_retention_payload(self) -> None:
|
||||
"""Settings PATCH schema keeps retention fields in serialized payloads."""
|
||||
|
||||
request_payload = AppSettingsUpdateRequest(
|
||||
processing_log_retention=ProcessingLogRetentionSettingsUpdateRequest(
|
||||
keep_document_sessions=7,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
request_payload.model_dump(exclude_none=True)["processing_log_retention"],
|
||||
{"keep_document_sessions": 7},
|
||||
)
|
||||
|
||||
def test_update_app_settings_merges_retention_block_and_sanitizes_values(self) -> None:
|
||||
"""Settings updates merge partial retention values and persist sanitized results."""
|
||||
|
||||
current_payload = _sample_current_payload()
|
||||
|
||||
with (
|
||||
patch.object(app_settings, "_read_raw_settings", return_value=current_payload),
|
||||
patch.object(app_settings, "read_app_settings", return_value={"processing_log_retention": {}}),
|
||||
patch.object(app_settings, "_write_settings") as write_settings_mock,
|
||||
):
|
||||
app_settings.update_app_settings(
|
||||
processing_log_retention={
|
||||
"keep_document_sessions": 9,
|
||||
"keep_unbound_entries": 999,
|
||||
}
|
||||
)
|
||||
|
||||
written_payload = write_settings_mock.call_args.args[0]
|
||||
self.assertEqual(
|
||||
written_payload["processing_log_retention"],
|
||||
{
|
||||
"keep_document_sessions": 9,
|
||||
"keep_unbound_entries": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_read_processing_log_retention_settings_returns_defaults_when_key_missing(self) -> None:
|
||||
"""Reader falls back to defaults when persisted payload omits retention key."""
|
||||
|
||||
payload_without_retention = _sample_current_payload()
|
||||
payload_without_retention.pop("processing_log_retention", None)
|
||||
with patch.object(app_settings, "_read_raw_settings", return_value=payload_without_retention):
|
||||
retention = app_settings.read_processing_log_retention_settings()
|
||||
self.assertEqual(
|
||||
retention,
|
||||
{
|
||||
"keep_document_sessions": 2,
|
||||
"keep_unbound_entries": 80,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,7 @@ This directory contains technical documentation for DMS.
|
||||
|
||||
- `../README.md` - project overview, setup, and quick operations
|
||||
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
||||
- `api-contract.md` - API endpoint contract grouped by route module
|
||||
- `api-contract.md` - API endpoint contract grouped by route module, including settings and processing-log trim defaults
|
||||
- `data-model-reference.md` - database entity definitions and lifecycle states
|
||||
- `operations-and-configuration.md` - runtime operations, ports, volumes, and configuration values
|
||||
- `frontend-design-foundation.md` - frontend visual system, tokens, and UI implementation rules
|
||||
- `operations-and-configuration.md` - runtime operations, ports, volumes, and persisted settings configuration
|
||||
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, processing-log timeline behavior, and settings helper-copy guidance
|
||||
|
||||
@@ -90,7 +90,8 @@ Primary implementation modules:
|
||||
- Query: `offset`, `limit`, `document_id`
|
||||
- Response model: `ProcessingLogListResponse`
|
||||
- `POST /processing/logs/trim`
|
||||
- Query: `keep_document_sessions`, `keep_unbound_entries`
|
||||
- Query: optional `keep_document_sessions`, `keep_unbound_entries`
|
||||
- Behavior: omitted query values fall back to persisted `/settings.processing_log_retention`
|
||||
- Response: trim counters
|
||||
- `POST /processing/logs/clear`
|
||||
- Response: clear counters
|
||||
@@ -127,4 +128,4 @@ Processing log schemas in `backend/app/schemas/processing_logs.py`:
|
||||
- `ProcessingLogListResponse`
|
||||
|
||||
Settings schemas in `backend/app/schemas/settings.py`:
|
||||
- Provider, task, upload-default, display, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.
|
||||
- Provider, task, upload-default, display, processing-log retention, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.
|
||||
|
||||
@@ -31,6 +31,7 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
||||
## Control Standards
|
||||
|
||||
- Global input, select, textarea, and button styles are defined once in `frontend/src/styles.css`.
|
||||
- Checkbox and radio controls must be styled through explicit `input[type='checkbox']` and `input[type='radio']` rules, not generic text-input selectors.
|
||||
- Variant button classes (`secondary-action`, `active-view-button`, `warning-action`, `danger-action`) are the only approved button color routes.
|
||||
- Tag chips, routing pills, card chips, and icon buttons must stay within the compact radius and spacing scale.
|
||||
- Focus states use `:focus-visible` and tokenized focus color to preserve keyboard discoverability.
|
||||
@@ -41,6 +42,13 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
||||
- Keep transitions brief and functional.
|
||||
- Avoid decorative animation loops outside explicit status indicators like terminal caret blink.
|
||||
|
||||
## Processing Log Timeline Behavior
|
||||
|
||||
- Keep processing log headers in strict arrival order when typing animation is enabled.
|
||||
- Buffer newly discovered entries and reveal only one active line at a time while it types.
|
||||
- Do not render queued headers before their animation starts, even when polling returns batched updates.
|
||||
- Preserve existing header content format and fold/unfold detail behavior as lines are revealed.
|
||||
|
||||
## Extension Checklist
|
||||
|
||||
When adding or redesigning a UI area:
|
||||
@@ -49,3 +57,10 @@ When adding or redesigning a UI area:
|
||||
3. Implement component styles in `frontend/src/styles.css` using existing layout and variant conventions.
|
||||
4. Validate responsive behavior at `1240px`, `1040px`, `760px`, and `560px` breakpoints.
|
||||
5. Verify keyboard focus visibility and text contrast before merging.
|
||||
|
||||
## Settings UX Copy
|
||||
|
||||
- Keep helper copy in settings short and plain language, especially on advanced model and threshold controls.
|
||||
- Prefer one concise hint per advanced control that explains practical impact rather than internals.
|
||||
- In the Workspace settings block, keep processing-log controls visually separated from default path and tag behavior.
|
||||
- Processing-log hints must explicitly state they affect logs and retention behavior, not document metadata values.
|
||||
|
||||
@@ -97,11 +97,14 @@ Application-level settings managed from the UI are persisted by backend settings
|
||||
Settings include:
|
||||
- upload defaults
|
||||
- display options
|
||||
- processing-log retention options (`keep_document_sessions`, `keep_unbound_entries`)
|
||||
- provider configuration
|
||||
- OCR, summary, and routing task settings
|
||||
- predefined paths and tags
|
||||
- handwriting-style clustering settings
|
||||
|
||||
Retention settings are used by worker cleanup and by `POST /api/v1/processing/logs/trim` when trim query values are not provided.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
After operational or configuration changes, verify:
|
||||
|
||||
@@ -6,18 +6,45 @@ import type { JSX } from 'react';
|
||||
|
||||
import type { ProcessingLogEntry } from '../types';
|
||||
|
||||
/**
|
||||
* Input contract for the processing timeline panel.
|
||||
*/
|
||||
interface ProcessingLogPanelProps {
|
||||
/**
|
||||
* Raw timeline entries returned by the API.
|
||||
*/
|
||||
entries: ProcessingLogEntry[];
|
||||
/**
|
||||
* Indicates that the parent screen is currently refreshing timeline data.
|
||||
*/
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* Indicates that the timeline clear action is currently pending.
|
||||
*/
|
||||
isClearing: boolean;
|
||||
/**
|
||||
* Currently selected document ID used for header highlighting.
|
||||
*/
|
||||
selectedDocumentId: string | null;
|
||||
/**
|
||||
* Indicates whether processing work is currently active for the workspace.
|
||||
*/
|
||||
isProcessingActive: boolean;
|
||||
/**
|
||||
* Enables typed header rendering for newly queued entries.
|
||||
*/
|
||||
typingAnimationEnabled: boolean;
|
||||
/**
|
||||
* Clears persisted processing logs.
|
||||
*/
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders processing events in a terminal-style stream with optional typed headers.
|
||||
* Renders processing events in a terminal-style stream with buffered sequential typing.
|
||||
*
|
||||
* New entries are queued and revealed one by one so batched timeline updates never render
|
||||
* full headers before the animation for that entry has completed.
|
||||
*/
|
||||
export default function ProcessingLogPanel({
|
||||
entries,
|
||||
@@ -29,11 +56,15 @@ export default function ProcessingLogPanel({
|
||||
onClear,
|
||||
}: ProcessingLogPanelProps): JSX.Element {
|
||||
const timeline = useMemo(() => [...entries].reverse(), [entries]);
|
||||
const [typedEntryIds, setTypedEntryIds] = useState<Set<number>>(() => new Set());
|
||||
const timelineById = useMemo(() => new Map(timeline.map((entry) => [entry.id, entry])), [timeline]);
|
||||
const [revealedEntryIds, setRevealedEntryIds] = useState<Set<number>>(() => new Set());
|
||||
const [pendingEntryIds, setPendingEntryIds] = useState<number[]>([]);
|
||||
const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
|
||||
const [typingHeader, setTypingHeader] = useState<string>('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const isBootstrapCompleteRef = useRef<boolean>(false);
|
||||
const knownEntryIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const formatTimestamp = (value: string): string => {
|
||||
const parsed = new Date(value);
|
||||
@@ -63,28 +94,120 @@ export default function ProcessingLogPanel({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const knownIds = new Set(typedEntryIds);
|
||||
if (typingEntryId !== null) {
|
||||
knownIds.add(typingEntryId);
|
||||
const timelineIdSet = new Set(timeline.map((entry) => entry.id));
|
||||
|
||||
knownEntryIdsRef.current.forEach((entryId) => {
|
||||
if (!timelineIdSet.has(entryId)) {
|
||||
knownEntryIdsRef.current.delete(entryId);
|
||||
}
|
||||
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id));
|
||||
if (!nextUntyped) {
|
||||
});
|
||||
|
||||
setRevealedEntryIds((current) => {
|
||||
const next = new Set<number>();
|
||||
current.forEach((entryId) => {
|
||||
if (timelineIdSet.has(entryId)) {
|
||||
next.add(entryId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setPendingEntryIds((current) => current.filter((entryId) => timelineIdSet.has(entryId)));
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set<number>();
|
||||
current.forEach((entryId) => {
|
||||
if (timelineIdSet.has(entryId)) {
|
||||
next.add(entryId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
if (typingEntryId !== null && !timelineIdSet.has(typingEntryId)) {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setTypingEntryId(null);
|
||||
setTypingHeader('');
|
||||
}
|
||||
|
||||
if (!isBootstrapCompleteRef.current) {
|
||||
if (timeline.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!typingAnimationEnabled) {
|
||||
setTypedEntryIds((current) => {
|
||||
isBootstrapCompleteRef.current = true;
|
||||
if (timeline.length > 1) {
|
||||
timeline.forEach((entry) => {
|
||||
knownEntryIdsRef.current.add(entry.id);
|
||||
});
|
||||
setRevealedEntryIds(new Set(timeline.map((entry) => entry.id)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredEntryIds: number[] = [];
|
||||
timeline.forEach((entry) => {
|
||||
if (!knownEntryIdsRef.current.has(entry.id)) {
|
||||
knownEntryIdsRef.current.add(entry.id);
|
||||
discoveredEntryIds.push(entry.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (discoveredEntryIds.length > 0) {
|
||||
setPendingEntryIds((current) => [...current, ...discoveredEntryIds]);
|
||||
}
|
||||
}, [timeline, typingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typingAnimationEnabled) {
|
||||
return;
|
||||
}
|
||||
if (typingEntryId === null && pendingEntryIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setRevealedEntryIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(nextUntyped.id);
|
||||
if (typingEntryId !== null) {
|
||||
next.add(typingEntryId);
|
||||
}
|
||||
pendingEntryIds.forEach((entryId) => {
|
||||
next.add(entryId);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setTypingEntryId(null);
|
||||
setTypingHeader('');
|
||||
setPendingEntryIds([]);
|
||||
}, [pendingEntryIds, typingAnimationEnabled, typingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typingEntryId !== null || pendingEntryIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntryId = pendingEntryIds[0];
|
||||
setPendingEntryIds((current) => current.slice(1));
|
||||
|
||||
if (!typingAnimationEnabled) {
|
||||
setRevealedEntryIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(nextEntryId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typingEntryId !== null) {
|
||||
|
||||
const nextEntry = timelineById.get(nextEntryId);
|
||||
if (!nextEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHeader = renderHeader(nextUntyped);
|
||||
setTypingEntryId(nextUntyped.id);
|
||||
const fullHeader = renderHeader(nextEntry);
|
||||
setTypingEntryId(nextEntryId);
|
||||
setTypingHeader('');
|
||||
let cursor = 0;
|
||||
timerRef.current = window.setInterval(() => {
|
||||
@@ -95,15 +218,16 @@ export default function ProcessingLogPanel({
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setTypedEntryIds((current) => {
|
||||
setRevealedEntryIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(nextUntyped.id);
|
||||
next.add(nextEntryId);
|
||||
return next;
|
||||
});
|
||||
setTypingEntryId(null);
|
||||
setTypingHeader('');
|
||||
}
|
||||
}, 10);
|
||||
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]);
|
||||
}, [pendingEntryIds, renderHeader, timelineById, typingAnimationEnabled, typingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -113,6 +237,14 @@ export default function ProcessingLogPanel({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visibleTimeline = useMemo(() => {
|
||||
const visibleEntryIds = new Set(revealedEntryIds);
|
||||
if (typingEntryId !== null) {
|
||||
visibleEntryIds.add(typingEntryId);
|
||||
}
|
||||
return timeline.filter((entry) => visibleEntryIds.has(entry.id));
|
||||
}, [revealedEntryIds, timeline, typingEntryId]);
|
||||
|
||||
return (
|
||||
<section className="processing-log-panel">
|
||||
<div className="panel-header">
|
||||
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
|
||||
<div className="processing-log-terminal-wrap">
|
||||
<div className="processing-log-terminal">
|
||||
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
|
||||
{timeline.map((entry, index) => {
|
||||
{visibleTimeline.map((entry, index) => {
|
||||
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`;
|
||||
const previousGroupKey = index > 0 ? `${timeline[index - 1].document_id ?? 'unbound'}:${timeline[index - 1].stage}` : null;
|
||||
const previousGroupKey = index > 0 ? `${visibleTimeline[index - 1].document_id ?? 'unbound'}:${visibleTimeline[index - 1].stage}` : null;
|
||||
const showSeparator = index > 0 && groupKey !== previousGroupKey;
|
||||
const isTyping = entry.id === typingEntryId;
|
||||
const isTyped = typedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
|
||||
const isTyped = revealedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
|
||||
const isExpanded = expandedIds.has(entry.id);
|
||||
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
|
||||
const hasDetails =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Dedicated settings screen for providers, task model bindings, and catalog controls.
|
||||
* Uses concise helper hints for advanced runtime and provider settings.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
DisplaySettings,
|
||||
HandwritingStyleClusteringSettings,
|
||||
OcrTaskSettings,
|
||||
ProcessingLogRetentionSettings,
|
||||
PredefinedPathEntry,
|
||||
PredefinedTagEntry,
|
||||
ProviderSettings,
|
||||
@@ -47,8 +49,26 @@ function parseCardsPerPageInput(input: string, fallback: number): number {
|
||||
return clampCardsPerPage(parsed);
|
||||
}
|
||||
|
||||
const DEFAULT_PROCESSING_LOG_RETENTION: ProcessingLogRetentionSettings = {
|
||||
keep_document_sessions: 2,
|
||||
keep_unbound_entries: 80,
|
||||
};
|
||||
|
||||
const PROCESSING_LOG_SESSION_MIN = 0;
|
||||
const PROCESSING_LOG_SESSION_MAX = 20;
|
||||
const PROCESSING_LOG_UNBOUND_MIN = 0;
|
||||
const PROCESSING_LOG_UNBOUND_MAX = 400;
|
||||
|
||||
function clampProcessingLogDocumentSessions(value: number): number {
|
||||
return Math.max(PROCESSING_LOG_SESSION_MIN, Math.min(PROCESSING_LOG_SESSION_MAX, value));
|
||||
}
|
||||
|
||||
function clampProcessingLogUnboundEntries(value: number): number {
|
||||
return Math.max(PROCESSING_LOG_UNBOUND_MIN, Math.min(PROCESSING_LOG_UNBOUND_MAX, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders compact human-oriented settings controls.
|
||||
* Renders compact human-oriented settings controls with plain-language hints.
|
||||
*/
|
||||
export default function SettingsScreen({
|
||||
settings,
|
||||
@@ -69,6 +89,7 @@ export default function SettingsScreen({
|
||||
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
|
||||
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
|
||||
const [processingLogRetention, setProcessingLogRetention] = useState<ProcessingLogRetentionSettings | null>(null);
|
||||
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -92,6 +113,15 @@ export default function SettingsScreen({
|
||||
setPredefinedTags(settings.predefined_tags);
|
||||
setUploadDefaults(settings.upload_defaults);
|
||||
setDisplaySettings(settings.display);
|
||||
setProcessingLogRetention({
|
||||
keep_document_sessions: clampProcessingLogDocumentSessions(
|
||||
settings.processing_log_retention?.keep_document_sessions ??
|
||||
DEFAULT_PROCESSING_LOG_RETENTION.keep_document_sessions,
|
||||
),
|
||||
keep_unbound_entries: clampProcessingLogUnboundEntries(
|
||||
settings.processing_log_retention?.keep_unbound_entries ?? DEFAULT_PROCESSING_LOG_RETENTION.keep_unbound_entries,
|
||||
),
|
||||
});
|
||||
setCardsPerPageInput(String(settings.display.cards_per_page));
|
||||
setError(null);
|
||||
}, [settings]);
|
||||
@@ -163,7 +193,15 @@ export default function SettingsScreen({
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
if (
|
||||
!ocrTask ||
|
||||
!summaryTask ||
|
||||
!routingTask ||
|
||||
!handwritingStyle ||
|
||||
!uploadDefaults ||
|
||||
!displaySettings ||
|
||||
!processingLogRetention
|
||||
) {
|
||||
setError('Settings are not fully loaded yet');
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +213,12 @@ export default function SettingsScreen({
|
||||
setError(null);
|
||||
try {
|
||||
const resolvedCardsPerPage = parseCardsPerPageInput(cardsPerPageInput, displaySettings.cards_per_page);
|
||||
const resolvedProcessingLogRetention: ProcessingLogRetentionSettings = {
|
||||
keep_document_sessions: clampProcessingLogDocumentSessions(processingLogRetention.keep_document_sessions),
|
||||
keep_unbound_entries: clampProcessingLogUnboundEntries(processingLogRetention.keep_unbound_entries),
|
||||
};
|
||||
setDisplaySettings({ ...displaySettings, cards_per_page: resolvedCardsPerPage });
|
||||
setProcessingLogRetention(resolvedProcessingLogRetention);
|
||||
setCardsPerPageInput(String(resolvedCardsPerPage));
|
||||
|
||||
await onSave({
|
||||
@@ -187,6 +230,7 @@ export default function SettingsScreen({
|
||||
cards_per_page: resolvedCardsPerPage,
|
||||
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
|
||||
},
|
||||
processing_log_retention: resolvedProcessingLogRetention,
|
||||
predefined_paths: predefinedPaths,
|
||||
predefined_tags: predefinedTags,
|
||||
handwriting_style_clustering: {
|
||||
@@ -252,21 +296,51 @@ export default function SettingsScreen({
|
||||
routingTask,
|
||||
summaryTask,
|
||||
uploadDefaults,
|
||||
processingLogRetention,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onRegisterSaveAction) {
|
||||
return;
|
||||
}
|
||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
if (
|
||||
!settings ||
|
||||
!ocrTask ||
|
||||
!summaryTask ||
|
||||
!routingTask ||
|
||||
!handwritingStyle ||
|
||||
!uploadDefaults ||
|
||||
!displaySettings ||
|
||||
!processingLogRetention
|
||||
) {
|
||||
onRegisterSaveAction(null);
|
||||
return;
|
||||
}
|
||||
onRegisterSaveAction(() => handleSave());
|
||||
return () => onRegisterSaveAction(null);
|
||||
}, [displaySettings, handleSave, handwritingStyle, ocrTask, onRegisterSaveAction, routingTask, settings, summaryTask, uploadDefaults]);
|
||||
}, [
|
||||
displaySettings,
|
||||
handleSave,
|
||||
handwritingStyle,
|
||||
ocrTask,
|
||||
onRegisterSaveAction,
|
||||
processingLogRetention,
|
||||
routingTask,
|
||||
settings,
|
||||
summaryTask,
|
||||
uploadDefaults,
|
||||
]);
|
||||
|
||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
if (
|
||||
!settings ||
|
||||
!ocrTask ||
|
||||
!summaryTask ||
|
||||
!routingTask ||
|
||||
!handwritingStyle ||
|
||||
!uploadDefaults ||
|
||||
!displaySettings ||
|
||||
!processingLogRetention
|
||||
) {
|
||||
return (
|
||||
<section className="settings-layout">
|
||||
<div className="settings-card">
|
||||
@@ -294,6 +368,7 @@ export default function SettingsScreen({
|
||||
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
|
||||
suggestions={knownPaths}
|
||||
/>
|
||||
<span className="settings-field-hint">Used when you upload without choosing a path.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Default Tags
|
||||
@@ -302,6 +377,7 @@ export default function SettingsScreen({
|
||||
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
|
||||
suggestions={knownTags}
|
||||
/>
|
||||
<span className="settings-field-hint">Added automatically when no tags are selected.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Cards Per Page
|
||||
@@ -312,8 +388,53 @@ export default function SettingsScreen({
|
||||
value={cardsPerPageInput}
|
||||
onChange={(event) => setCardsPerPageInput(event.target.value)}
|
||||
/>
|
||||
<span className="settings-field-hint">Controls how many documents you see at once.</span>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<div className="settings-subsection-divider">
|
||||
<h4>Processing Log Controls</h4>
|
||||
<p className="small">
|
||||
These settings affect processing logs only. They do not change default path, tags, or document cards.
|
||||
</p>
|
||||
</div>
|
||||
<label className="settings-field">
|
||||
Keep document sessions
|
||||
<input
|
||||
type="number"
|
||||
min={PROCESSING_LOG_SESSION_MIN}
|
||||
max={PROCESSING_LOG_SESSION_MAX}
|
||||
value={processingLogRetention.keep_document_sessions}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
setProcessingLogRetention({
|
||||
...processingLogRetention,
|
||||
keep_document_sessions: clampProcessingLogDocumentSessions(nextValue),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="settings-field-hint">How many recent log sessions to keep for each document.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Keep unbound entries
|
||||
<input
|
||||
type="number"
|
||||
min={PROCESSING_LOG_UNBOUND_MIN}
|
||||
max={PROCESSING_LOG_UNBOUND_MAX}
|
||||
value={processingLogRetention.keep_unbound_entries}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
setProcessingLogRetention({
|
||||
...processingLogRetention,
|
||||
keep_unbound_entries: clampProcessingLogUnboundEntries(nextValue),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="settings-field-hint">How many standalone log entries to keep.</span>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field settings-checkbox-with-hint">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displaySettings.log_typing_animation_enabled}
|
||||
@@ -321,8 +442,14 @@ export default function SettingsScreen({
|
||||
setDisplaySettings({ ...displaySettings, log_typing_animation_enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="settings-checkbox-copy">
|
||||
Processing log typing animation enabled
|
||||
<span className="settings-field-hint">Shows new log text as a type-in animation.</span>
|
||||
</span>
|
||||
</label>
|
||||
<p className="small settings-helper-text">
|
||||
Processing-log retention values are used by backend trim routines when pruning historical entries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,6 +580,7 @@ export default function SettingsScreen({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="settings-field-hint">Task settings use this ID to select the provider.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Label
|
||||
@@ -484,6 +612,7 @@ export default function SettingsScreen({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="settings-field-hint">Stop waiting after this many seconds if a call hangs.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Base URL
|
||||
@@ -497,6 +626,7 @@ export default function SettingsScreen({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="settings-field-hint">API endpoint root for this provider.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
API Key
|
||||
@@ -512,6 +642,7 @@ export default function SettingsScreen({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="settings-field-hint">Leave blank to keep the current stored key.</span>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<input
|
||||
@@ -566,10 +697,12 @@ export default function SettingsScreen({
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={ocrTask.model} onChange={(event) => setOcrTask({ ...ocrTask, model: event.target.value })} />
|
||||
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
OCR Prompt
|
||||
<textarea value={ocrTask.prompt} onChange={(event) => setOcrTask({ ...ocrTask, prompt: event.target.value })} />
|
||||
<span className="settings-field-hint">Instructions used when reading handwriting text.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -596,6 +729,7 @@ export default function SettingsScreen({
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={summaryTask.model} onChange={(event) => setSummaryTask({ ...summaryTask, model: event.target.value })} />
|
||||
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Max Input Tokens
|
||||
@@ -611,10 +745,12 @@ export default function SettingsScreen({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="settings-field-hint">Long inputs are trimmed to this size before summarizing.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Summary Prompt
|
||||
<textarea value={summaryTask.prompt} onChange={(event) => setSummaryTask({ ...summaryTask, prompt: event.target.value })} />
|
||||
<span className="settings-field-hint">Instructions that shape the generated summary.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -641,42 +777,56 @@ export default function SettingsScreen({
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={routingTask.model} onChange={(event) => setRoutingTask({ ...routingTask, model: event.target.value })} />
|
||||
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Neighbor Count
|
||||
<input type="number" value={routingTask.neighbor_count} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_count: Number.parseInt(event.target.value, 10) || routingTask.neighbor_count })} />
|
||||
<span className="settings-field-hint">How many close matches to compare before routing.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Min Neighbor Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_min_similarity })} />
|
||||
<span className="settings-field-hint">Ignore neighbors below this match score.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Auto Apply Confidence
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_confidence_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_confidence_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_confidence_threshold })} />
|
||||
<span className="settings-field-hint">Minimum model confidence for automatic changes.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Auto Apply Neighbor Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_neighbor_similarity_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_neighbor_similarity_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_neighbor_similarity_threshold })} />
|
||||
<span className="settings-field-hint">Minimum neighbor score for automatic changes.</span>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<label className="inline-checkbox settings-checkbox-field settings-checkbox-with-hint">
|
||||
<input type="checkbox" checked={routingTask.neighbor_path_override_enabled} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_enabled: event.target.checked })} />
|
||||
<span className="settings-checkbox-copy">
|
||||
Dominant neighbor path override enabled
|
||||
<span className="settings-field-hint">
|
||||
If a strong top match disagrees with the model, use the top match path instead.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_similarity })} />
|
||||
<span className="settings-field-hint">Top neighbor must reach this score to override.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Min Gap
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_gap} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_gap: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_gap })} />
|
||||
<span className="settings-field-hint">Top match must beat the second match by at least this gap.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Max LLM Confidence
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_max_confidence} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_max_confidence: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_max_confidence })} />
|
||||
<span className="settings-field-hint">Override only runs when model confidence is at or below this level.</span>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Routing Prompt
|
||||
<textarea value={routingTask.prompt} onChange={(event) => setRoutingTask({ ...routingTask, prompt: event.target.value })} />
|
||||
<span className="settings-field-hint">Instructions used when deciding document path and tags.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -693,26 +843,32 @@ export default function SettingsScreen({
|
||||
<label className="settings-field settings-field-wide">
|
||||
Typesense Embedding Model Slug
|
||||
<input value={handwritingStyle.embed_model} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, embed_model: event.target.value })} />
|
||||
<span className="settings-field-hint">Embedding model used to compare handwriting style similarity.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Neighbor Limit
|
||||
<input type="number" min={1} max={32} value={handwritingStyle.neighbor_limit} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, neighbor_limit: Number.parseInt(event.target.value, 10) || handwritingStyle.neighbor_limit })} />
|
||||
<span className="settings-field-hint">How many nearby samples to check during matching.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Match Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.match_min_similarity })} />
|
||||
<span className="settings-field-hint">Minimum similarity needed to treat two styles as a match.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Bootstrap Match Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.bootstrap_match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.bootstrap_match_min_similarity })} />
|
||||
<span className="settings-field-hint">Stricter match score used only while bootstrapping new clusters.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Bootstrap Sample Size
|
||||
<input type="number" min={1} max={30} value={handwritingStyle.bootstrap_sample_size} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_sample_size: Number.parseInt(event.target.value, 10) || handwritingStyle.bootstrap_sample_size })} />
|
||||
<span className="settings-field-hint">Number of samples used to start each new style cluster.</span>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Max Image Side (px)
|
||||
<input type="number" min={256} max={4096} value={handwritingStyle.image_max_side} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, image_max_side: Number.parseInt(event.target.value, 10) || handwritingStyle.image_max_side })} />
|
||||
<span className="settings-field-hint">Resizes large images to this limit before analysis.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
input,
|
||||
input:not([type='checkbox']):not([type='radio']),
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
@@ -86,24 +86,39 @@ textarea {
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
input:not([type='checkbox']):not([type='radio'])::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #72819e;
|
||||
}
|
||||
|
||||
input:hover,
|
||||
input:not([type='checkbox']):not([type='radio']):hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
input:not([type='checkbox']):not([type='radio']):focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(63, 141, 255, 0.2);
|
||||
}
|
||||
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='checkbox']:focus-visible,
|
||||
input[type='radio']:focus-visible {
|
||||
outline: 2px solid rgba(63, 141, 255, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
padding-right: 1.9rem;
|
||||
@@ -961,23 +976,69 @@ button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.settings-field-hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: #95a6c4;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-subsection-divider {
|
||||
grid-column: span 12;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding-top: 0.2rem;
|
||||
border-top: 1px solid rgba(70, 89, 122, 0.55);
|
||||
}
|
||||
|
||||
.settings-subsection-divider h4 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.settings-subsection-divider p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-checkbox {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 1.95rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px solid rgba(70, 89, 122, 0.55);
|
||||
border-radius: var(--radius-xs);
|
||||
background: rgba(18, 27, 41, 0.62);
|
||||
font-size: 0.79rem;
|
||||
color: #cad7ed;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inline-checkbox input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-checkbox input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
color: #dbe8ff;
|
||||
}
|
||||
|
||||
.settings-checkbox-with-hint {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-checkbox-copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.settings-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -176,6 +176,14 @@ export interface DisplaySettings {
|
||||
log_typing_animation_enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents retention targets used when trimming persisted processing logs.
|
||||
*/
|
||||
export interface ProcessingLogRetentionSettings {
|
||||
keep_document_sessions: number;
|
||||
keep_unbound_entries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one predefined logical path and discoverability scope.
|
||||
*/
|
||||
@@ -220,6 +228,7 @@ export interface TaskSettings {
|
||||
export interface AppSettings {
|
||||
upload_defaults: UploadDefaultsSettings;
|
||||
display: DisplaySettings;
|
||||
processing_log_retention: ProcessingLogRetentionSettings;
|
||||
handwriting_style_clustering: HandwritingStyleClusteringSettings;
|
||||
predefined_paths: PredefinedPathEntry[];
|
||||
predefined_tags: PredefinedTagEntry[];
|
||||
@@ -265,6 +274,14 @@ export interface DisplaySettingsUpdate {
|
||||
log_typing_animation_enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents processing-log retention update payload.
|
||||
*/
|
||||
export interface ProcessingLogRetentionSettingsUpdate {
|
||||
keep_document_sessions?: number;
|
||||
keep_unbound_entries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents handwriting-style clustering settings update payload.
|
||||
*/
|
||||
@@ -284,6 +301,7 @@ export interface HandwritingStyleClusteringSettingsUpdate {
|
||||
export interface AppSettingsUpdate {
|
||||
upload_defaults?: UploadDefaultsSettingsUpdate;
|
||||
display?: DisplaySettingsUpdate;
|
||||
processing_log_retention?: ProcessingLogRetentionSettingsUpdate;
|
||||
handwriting_style_clustering?: HandwritingStyleClusteringSettingsUpdate;
|
||||
predefined_paths?: PredefinedPathEntry[];
|
||||
predefined_tags?: PredefinedTagEntry[];
|
||||
|
||||
Reference in New Issue
Block a user