Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/ocr.yml +21 -0
- .gitignore +26 -0
- .hfignore +48 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +493 -0
- Agent.md +154 -0
- Dockerfile +23 -0
- LICENSE +21 -0
- README.md +284 -10
- api.py +737 -0
- api_README.md +500 -0
- atk-interface/README.md +12 -0
- atk-interface/api_README.md +500 -0
- atk-interface/eslint.config.js +33 -0
- atk-interface/index.html +13 -0
- atk-interface/package-lock.json +0 -0
- atk-interface/package.json +38 -0
- atk-interface/pages_README.md +134 -0
- atk-interface/postcss.config.js +6 -0
- atk-interface/public/vite.svg +1 -0
- atk-interface/src/.gitignore +24 -0
- atk-interface/src/App.css +42 -0
- atk-interface/src/App.jsx +40 -0
- atk-interface/src/api.js +490 -0
- atk-interface/src/assets/atk-logo-notscuffed.png +0 -0
- atk-interface/src/assets/react.svg +1 -0
- atk-interface/src/components/ButtonParticles.jsx +79 -0
- atk-interface/src/components/DocumentStackBackground.jsx +315 -0
- atk-interface/src/components/EnhancedButton.jsx +96 -0
- atk-interface/src/components/FireworksBackground.jsx +261 -0
- atk-interface/src/components/IdleMonitorGraphic.jsx +65 -0
- atk-interface/src/components/Layout.jsx +147 -0
- atk-interface/src/components/Modal.jsx +67 -0
- atk-interface/src/components/PourOverBackground.jsx +224 -0
- atk-interface/src/components/ProgressBarParticles.jsx +384 -0
- atk-interface/src/components/ScrollingTextBackground.jsx +92 -0
- atk-interface/src/context/FileManagerContext.jsx +112 -0
- atk-interface/src/context/NavigationContext.jsx +19 -0
- atk-interface/src/context/TaskHistoryContext.jsx +60 -0
- atk-interface/src/index.css +3 -0
- atk-interface/src/main.jsx +16 -0
- atk-interface/src/pages/Chat.jsx +550 -0
- atk-interface/src/pages/ConfigEditor.jsx +595 -0
- atk-interface/src/pages/ConfigSelector.jsx +1011 -0
- atk-interface/src/pages/HomePage.jsx +21 -0
- atk-interface/src/pages/InputManager.jsx +992 -0
- atk-interface/src/pages/LogManager.jsx +514 -0
.github/workflows/ocr.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Hybrid OCR offline tests
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
paths: ['ocr/**', 'augmentoolkit/utils/offline_chunking.py', 'generation/core_components/chunking.py', 'pytest-ocr.ini', '.github/workflows/ocr.yml']
|
| 5 |
+
pull_request:
|
| 6 |
+
paths: ['ocr/**', 'augmentoolkit/utils/offline_chunking.py', 'generation/core_components/chunking.py', 'pytest-ocr.ini', '.github/workflows/ocr.yml']
|
| 7 |
+
permissions:
|
| 8 |
+
contents: read
|
| 9 |
+
jobs:
|
| 10 |
+
tests:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v4
|
| 14 |
+
- uses: actions/setup-python@v5
|
| 15 |
+
with:
|
| 16 |
+
python-version: '3.11'
|
| 17 |
+
- run: python -m pip install --require-hashes -r ocr/requirements.lock
|
| 18 |
+
- run: python -m pip install -r ocr/requirements-dev.txt
|
| 19 |
+
- run: ruff check ocr augmentoolkit/utils/offline_chunking.py
|
| 20 |
+
- run: ruff format --check ocr augmentoolkit/utils/offline_chunking.py
|
| 21 |
+
- run: python -m pytest -c pytest-ocr.ini
|
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.vscode
|
| 2 |
+
.DS_Store
|
| 3 |
+
_old_project
|
| 4 |
+
outputs/*
|
| 5 |
+
*.pyc
|
| 6 |
+
**/__pycache__/
|
| 7 |
+
|
| 8 |
+
**/*.pyc
|
| 9 |
+
cache/*
|
| 10 |
+
inputs/meta/*
|
| 11 |
+
inputs/meta*
|
| 12 |
+
**/spicy_prompts
|
| 13 |
+
**/hidden*
|
| 14 |
+
test_huey*
|
| 15 |
+
huey/*
|
| 16 |
+
logs/*
|
| 17 |
+
!logs/.gitkeep
|
| 18 |
+
atk-interface/dist/
|
| 19 |
+
atk-interface/node_modules/
|
| 20 |
+
.venv
|
| 21 |
+
.venv/*
|
| 22 |
+
**/spicy_obfuscation
|
| 23 |
+
generation/core_pipelines/grep_sources
|
| 24 |
+
llama.cpp
|
| 25 |
+
models/**
|
| 26 |
+
helper_process_logs/
|
.hfignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.ipynb_checkpoints/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
.DS_Store
|
| 10 |
+
|
| 11 |
+
# Frontend build artifacts and dependencies
|
| 12 |
+
atk-interface/node_modules/
|
| 13 |
+
atk-interface/dist/
|
| 14 |
+
|
| 15 |
+
# Local logs and temp files
|
| 16 |
+
helper_process_logs/
|
| 17 |
+
logs/
|
| 18 |
+
npm_dev.log
|
| 19 |
+
uvicorn_dev.log
|
| 20 |
+
llm_server_dev.log
|
| 21 |
+
|
| 22 |
+
# Data and configs (we want a clean slate on HF Space usually, or just default examples)
|
| 23 |
+
# Keep only necessary examples if any
|
| 24 |
+
inputs/
|
| 25 |
+
outputs/
|
| 26 |
+
external_configs/
|
| 27 |
+
models/
|
| 28 |
+
|
| 29 |
+
# Exclude scripts used for local setup/cleanup
|
| 30 |
+
clean_hf_space.py
|
| 31 |
+
download_model.py
|
| 32 |
+
!inputs/.gitkeep
|
| 33 |
+
!outputs/.gitkeep
|
| 34 |
+
!external_configs/.gitkeep
|
| 35 |
+
!models/.gitkeep
|
| 36 |
+
|
| 37 |
+
docs/
|
| 38 |
+
cli_utils/
|
| 39 |
+
images/
|
| 40 |
+
tests/
|
| 41 |
+
api_README.md
|
| 42 |
+
linux.sh
|
| 43 |
+
local_linux.sh
|
| 44 |
+
local_macos.sh
|
| 45 |
+
macos.sh
|
| 46 |
+
pytest-ocr.ini
|
| 47 |
+
spec.md
|
| 48 |
+
spec (10).md
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"ocr/tests/test_agent_api.py::test_health_public",
|
| 3 |
+
"ocr/tests/test_agent_api.py::test_ready_unauthorized",
|
| 4 |
+
"ocr/tests/test_agent_api.py::test_v1_chat_completions_invalid_model",
|
| 5 |
+
"ocr/tests/test_agent_api.py::test_v1_chat_completions_invalid_url",
|
| 6 |
+
"ocr/tests/test_agent_api.py::test_v1_models_authorized",
|
| 7 |
+
"ocr/tests/test_agent_api.py::test_v1_ocr_page_wrong_sensitivity",
|
| 8 |
+
"ocr/tests/test_alias.py::test_grounding_and_boxes",
|
| 9 |
+
"ocr/tests/test_alias.py::test_invalid_reply[response0-alias_empty]",
|
| 10 |
+
"ocr/tests/test_alias.py::test_invalid_reply[response1-alias_truncated]",
|
| 11 |
+
"ocr/tests/test_alias.py::test_invalid_reply[response2-alias_empty]",
|
| 12 |
+
"ocr/tests/test_alias.py::test_invalid_reply[response3-alias_empty]",
|
| 13 |
+
"ocr/tests/test_alias.py::test_invalid_reply[response4-alias_reasoning_output]",
|
| 14 |
+
"ocr/tests/test_alias.py::test_probe_does_not_qualify_destination[False]",
|
| 15 |
+
"ocr/tests/test_alias.py::test_probe_does_not_qualify_destination[True]",
|
| 16 |
+
"ocr/tests/test_alias.py::test_retryable[429]",
|
| 17 |
+
"ocr/tests/test_alias.py::test_retryable[502]",
|
| 18 |
+
"ocr/tests/test_alias.py::test_retryable[503]",
|
| 19 |
+
"ocr/tests/test_alias.py::test_untrusted_geometry_rejected",
|
| 20 |
+
"ocr/tests/test_anonymization_engine.py::test_contact_patterns[12345 is a number.-12345 is a number.]",
|
| 21 |
+
"ocr/tests/test_anonymization_engine.py::test_contact_patterns[Mail: f\\xf6\\xf6@example.de.\\nCall +49 30 12345678!-Mail: [EMAIL].\\nCall [PHONE]!]",
|
| 22 |
+
"ocr/tests/test_anonymization_engine.py::test_contact_patterns[test@example.com and 555-0199.-[EMAIL] and [PHONE].]",
|
| 23 |
+
"ocr/tests/test_anonymization_engine.py::test_containment_and_duplicate_policy",
|
| 24 |
+
"ocr/tests/test_anonymization_engine.py::test_crossing_or_conflicting_overlap_blocks_whole_transformation",
|
| 25 |
+
"ocr/tests/test_anonymization_engine.py::test_dehyphenation_preserves_original_character_mapping",
|
| 26 |
+
"ocr/tests/test_anonymization_engine.py::test_entity_level_evaluator_separates_labels_coverage_and_partial_masking",
|
| 27 |
+
"ocr/tests/test_anonymization_engine.py::test_every_qualification_gold_span_replaces_exactly",
|
| 28 |
+
"ocr/tests/test_anonymization_engine.py::test_recognizer_maps_normalized_predictions_without_inventing_offsets",
|
| 29 |
+
"ocr/tests/test_anonymization_engine.py::test_repeated_umlaut_hyphenated_broken_words_preserve_other_spacing",
|
| 30 |
+
"ocr/tests/test_anonymization_integration.py::test_gate03c_frozen_holdout_canary_acceptance",
|
| 31 |
+
"ocr/tests/test_anonymization_integration.py::test_gate03d_recorded_first_holdout_acceptance",
|
| 32 |
+
"ocr/tests/test_anonymization_integration.py::test_gate03d_selected_hybrid_development_regression",
|
| 33 |
+
"ocr/tests/test_anonymization_integration.py::test_gate03e_recorded_current_candidate_coverage_gate",
|
| 34 |
+
"ocr/tests/test_anonymization_integration.py::test_gate03f_recorded_private_correction_utility",
|
| 35 |
+
"ocr/tests/test_anonymization_integration.py::test_real_pinned_recognition_and_ocr_handoff",
|
| 36 |
+
"ocr/tests/test_anonymization_integration.py::test_real_synthetic_canary_release_qualification",
|
| 37 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_acceptance_fail_closed_handoff",
|
| 38 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_acceptance_malformed_outputs_blocked",
|
| 39 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_baseline_nltk_fail_open_security_flaw",
|
| 40 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_evaluate_synthetic_dataset",
|
| 41 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_ocr_to_anonymization_integration",
|
| 42 |
+
"ocr/tests/test_anonymization_qualification.py::TestAnonymizationQualification::test_synthetic_fixtures_spans_are_valid",
|
| 43 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[approval_id-11111111111111111111111111111111]",
|
| 44 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[candidate_digest-0000000000000000000000000000000000000000000000000000000000000000]",
|
| 45 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[destination-value6]",
|
| 46 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[expires_at-0]",
|
| 47 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[format-text]",
|
| 48 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[issued_at-9999999999]",
|
| 49 |
+
"ocr/tests/test_anonymization_release_policy.py::test_approval_tampering_or_expiration_rejected[signature-0000000000000000000000000000000000000000000000000000000000000000]",
|
| 50 |
+
"ocr/tests/test_anonymization_release_policy.py::test_authority_has_no_default_identity_review_or_replay_authorization",
|
| 51 |
+
"ocr/tests/test_anonymization_release_policy.py::test_boolean_review_claims_are_not_verified_evidence",
|
| 52 |
+
"ocr/tests/test_anonymization_release_policy.py::test_candidate_changes_and_forged_copies_rejected",
|
| 53 |
+
"ocr/tests/test_anonymization_release_policy.py::test_d0_text_requires_separately_scoped_approval",
|
| 54 |
+
"ocr/tests/test_anonymization_release_policy.py::test_expired_approval_and_acl_revocation_rechecked_at_export",
|
| 55 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed0]",
|
| 56 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed1]",
|
| 57 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed2]",
|
| 58 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed3]",
|
| 59 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed4]",
|
| 60 |
+
"ocr/tests/test_anonymization_release_policy.py::test_export_cannot_change_identity_destination_or_format[changed5]",
|
| 61 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[candidate_digest-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]",
|
| 62 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[completed_at-0]",
|
| 63 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[completed_at-9999999999]",
|
| 64 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[destination_id-private-other]",
|
| 65 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[review_id-wrong]",
|
| 66 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[reviewer_subject-another-subject]",
|
| 67 |
+
"ocr/tests/test_anonymization_release_policy.py::test_incomplete_stale_or_mismatched_review_evidence_rejected[sensitivity-D0]",
|
| 68 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed0]",
|
| 69 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed1]",
|
| 70 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed2]",
|
| 71 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed3]",
|
| 72 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed4]",
|
| 73 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed5]",
|
| 74 |
+
"ocr/tests/test_anonymization_release_policy.py::test_issue_requires_authenticated_review_and_approved_private_destination[changed6]",
|
| 75 |
+
"ocr/tests/test_anonymization_release_policy.py::test_ledger_unavailability_never_returns_export",
|
| 76 |
+
"ocr/tests/test_anonymization_release_policy.py::test_legacy_boolean_review_or_v1_approval_cannot_bypass_new_boundary",
|
| 77 |
+
"ocr/tests/test_anonymization_release_policy.py::test_legacy_dictionary_cannot_receive_approval",
|
| 78 |
+
"ocr/tests/test_anonymization_release_policy.py::test_replay_ledger_persists_across_authority_instances_and_races",
|
| 79 |
+
"ocr/tests/test_anonymization_release_policy.py::test_replay_ledger_rejects_shared_permissions_or_symlink",
|
| 80 |
+
"ocr/tests/test_anonymization_release_policy.py::test_review_record_changes_invalidate_previous_approval",
|
| 81 |
+
"ocr/tests/test_anonymization_release_policy.py::test_reviewed_export_is_destination_bound_classified_and_single_use[D0]",
|
| 82 |
+
"ocr/tests/test_anonymization_release_policy.py::test_reviewed_export_is_destination_bound_classified_and_single_use[D1]",
|
| 83 |
+
"ocr/tests/test_anonymization_release_policy.py::test_reviewed_export_is_destination_bound_classified_and_single_use[D2]",
|
| 84 |
+
"ocr/tests/test_anonymization_release_policy.py::test_sensitive_public_destination_is_denied_even_by_permissive_application",
|
| 85 |
+
"ocr/tests/test_anonymization_release_policy.py::test_signed_approval_json_roundtrip_preserves_identity_and_destination_binding",
|
| 86 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_atomic_candidate_failure_has_no_partial_target_or_staging_file[flush]",
|
| 87 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_atomic_candidate_failure_has_no_partial_target_or_staging_file[fsync]",
|
| 88 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_atomic_candidate_failure_has_no_partial_target_or_staging_file[link]",
|
| 89 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_atomic_candidate_failure_has_no_partial_target_or_staging_file[write]",
|
| 90 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_cached_tokenizer_and_missing_nltk_corpus_preserve_chunking",
|
| 91 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_chunking_import_does_not_download_nltk_or_model_files",
|
| 92 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_missing_cached_tokenizer_uses_reversible_conservative_fallback",
|
| 93 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_publication_is_complete_atomic_private_and_no_overwrite",
|
| 94 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_short_write_never_publishes_partial_candidate",
|
| 95 |
+
"ocr/tests/test_anonymization_runtime_safety.py::test_symlink_or_racing_writer_cannot_be_overwritten",
|
| 96 |
+
"ocr/tests/test_anonymization_security.py::test_bad_placeholders_block_transformation",
|
| 97 |
+
"ocr/tests/test_anonymization_security.py::test_cli_creates_private_classified_candidate_and_never_overwrites",
|
| 98 |
+
"ocr/tests/test_anonymization_security.py::test_cli_failure_never_writes_a_candidate",
|
| 99 |
+
"ocr/tests/test_anonymization_security.py::test_empty_predictions_require_review_and_never_claim_success",
|
| 100 |
+
"ocr/tests/test_anonymization_security.py::test_failure_on_second_page_returns_no_partial_candidate",
|
| 101 |
+
"ocr/tests/test_anonymization_security.py::test_incomplete_or_inconsistent_evidence_fails_closed[classification]",
|
| 102 |
+
"ocr/tests/test_anonymization_security.py::test_incomplete_or_inconsistent_evidence_fails_closed[duplicate]",
|
| 103 |
+
"ocr/tests/test_anonymization_security.py::test_incomplete_or_inconsistent_evidence_fails_closed[failure]",
|
| 104 |
+
"ocr/tests/test_anonymization_security.py::test_incomplete_or_inconsistent_evidence_fails_closed[missing]",
|
| 105 |
+
"ocr/tests/test_anonymization_security.py::test_incomplete_or_inconsistent_evidence_fails_closed[partial]",
|
| 106 |
+
"ocr/tests/test_anonymization_security.py::test_inference_without_runtime_is_sanitized",
|
| 107 |
+
"ocr/tests/test_anonymization_security.py::test_invalid_runtime_configuration_is_sanitized[options0]",
|
| 108 |
+
"ocr/tests/test_anonymization_security.py::test_invalid_runtime_configuration_is_sanitized[options1]",
|
| 109 |
+
"ocr/tests/test_anonymization_security.py::test_invalid_runtime_configuration_is_sanitized[options2]",
|
| 110 |
+
"ocr/tests/test_anonymization_security.py::test_invalid_runtime_configuration_is_sanitized[options3]",
|
| 111 |
+
"ocr/tests/test_anonymization_security.py::test_invalid_runtime_configuration_is_sanitized[options4]",
|
| 112 |
+
"ocr/tests/test_anonymization_security.py::test_legacy_pipeline_flag_blocks_before_processing_or_external_egress",
|
| 113 |
+
"ocr/tests/test_anonymization_security.py::test_model_copy_cannot_bypass_span_validation",
|
| 114 |
+
"ocr/tests/test_anonymization_security.py::test_model_initialization_exception_is_sanitized",
|
| 115 |
+
"ocr/tests/test_anonymization_security.py::test_model_manifest_corruption_is_rejected",
|
| 116 |
+
"ocr/tests/test_anonymization_security.py::test_model_unavailable_fails_without_any_download",
|
| 117 |
+
"ocr/tests/test_anonymization_security.py::test_new_handoff_retains_classification_and_discards_reidentifying_metadata[D0]",
|
| 118 |
+
"ocr/tests/test_anonymization_security.py::test_new_handoff_retains_classification_and_discards_reidentifying_metadata[D1]",
|
| 119 |
+
"ocr/tests/test_anonymization_security.py::test_new_handoff_retains_classification_and_discards_reidentifying_metadata[D2]",
|
| 120 |
+
"ocr/tests/test_anonymization_security.py::test_unsupported_categories_or_external_recognizer_blocked[categories-value0]",
|
| 121 |
+
"ocr/tests/test_anonymization_security.py::test_unsupported_categories_or_external_recognizer_blocked[local-False]",
|
| 122 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[bool_offset]",
|
| 123 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[exception]",
|
| 124 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[extra]",
|
| 125 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[invalid_label]",
|
| 126 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[invalid_score]",
|
| 127 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[nan_score]",
|
| 128 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[negative]",
|
| 129 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[none]",
|
| 130 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[past_end]",
|
| 131 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[string]",
|
| 132 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_model_outputs_never_become_candidates[wrong_slice]",
|
| 133 |
+
"ocr/tests/test_anonymization_security.py::test_untrusted_safe_error_cannot_smuggle_source_into_error_response",
|
| 134 |
+
"ocr/tests/test_api_jobs.py::test_auth_downgrade_idempotency_cancel",
|
| 135 |
+
"ocr/tests/test_api_jobs.py::test_out_of_order_mixed_and_partial",
|
| 136 |
+
"ocr/tests/test_api_jobs.py::test_ttl_and_capacity",
|
| 137 |
+
"ocr/tests/test_api_jobs.py::test_two_lane_out_of_order_assembly",
|
| 138 |
+
"ocr/tests/test_api_jobs.py::test_worker_auth_limits_ready",
|
| 139 |
+
"ocr/tests/test_facade.py::test_facade_and_rejections",
|
| 140 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_direct_partial_utf8_decode_fails_instead_of_silently_losing_character",
|
| 141 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_existing_conversation_suffix_truncation_preserves_utf8_characters",
|
| 142 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_frozen_holdout_integrity_and_control_unchanged",
|
| 143 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_full_and_every_truncation_boundary_roundtrip[M\\xfcnchen, Gr\\xfc\\xdfe!]",
|
| 144 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_full_and_every_truncation_boundary_roundtrip[\\u0928\\u092e\\u0938\\u094d\\u0924\\u0947 \\u0926\\u0941\\u0928\\u093f\\u092f\\u093e]",
|
| 145 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_full_and_every_truncation_boundary_roundtrip[\\u6f22\\u5b57\\U0001f642a\\xe9\\xdf]",
|
| 146 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_full_and_every_truncation_boundary_roundtrip[na\\xefve caf\\xe9 \\U0001f30d]",
|
| 147 |
+
"ocr/tests/test_gate03c_holdout_utf8.py::test_full_and_every_truncation_boundary_roundtrip[plain ASCII]",
|
| 148 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_classified_cleanup_retains_evidence_and_respects_active_writer_lock",
|
| 149 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_killed_writer_recovery_removes_only_private_abandoned_stage",
|
| 150 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_multi_host_keys_cannot_share_independent_local_ledgers",
|
| 151 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_persistent_multiprocess_race_restart_and_rotation",
|
| 152 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_storage_failure_never_reinitializes_or_releases[corrupt]",
|
| 153 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_storage_failure_never_reinitializes_or_releases[identity_mismatch]",
|
| 154 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_storage_failure_never_reinitializes_or_releases[missing]",
|
| 155 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_storage_failure_never_reinitializes_or_releases[permissions]",
|
| 156 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_storage_failure_never_reinitializes_or_releases[replacement]",
|
| 157 |
+
"ocr/tests/test_gate03c_runtime_state.py::test_termination_rolls_back_and_committed_consumption_survives",
|
| 158 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_disable_ocr_and_bounded_operator_policy",
|
| 159 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[A religiously uncommitted position is quoted.-expected2]",
|
| 160 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Angabe: keine Konfession.-expected0]",
|
| 161 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Er bestritt 'keiner Religion' anzugeh\\xf6ren.-expected1]",
|
| 162 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Ohne Religion, nicht ohne Humor.-expected4]",
|
| 163 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Scan: konfessi0nsfrei-expected7]",
|
| 164 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Scan: konfessionsfre1-expected6]",
|
| 165 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[Status: NONRELIGI0US-expected5]",
|
| 166 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[The essay concerns religiously unaffiliated citizens.-expected3]",
|
| 167 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_general_status_grammar_and_exact_offsets[\\U0001f4a1 nonreli-\\ngi0us-expected8]",
|
| 168 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_historical_first_runner_refuses_updated_implementation",
|
| 169 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[Archivs]",
|
| 170 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[Hafenbogen]",
|
| 171 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[Religiously sophisticated art]",
|
| 172 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[\\xfcbernonreligi0us]",
|
| 173 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[a 12-character number]",
|
| 174 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[keine Religionszugeh\\xf6rigkeit behauptet]",
|
| 175 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[konfess10nsfre1]",
|
| 176 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[konfessXonsfrei]",
|
| 177 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[none religious]",
|
| 178 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[nonreligi00s]",
|
| 179 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[nonreligi0us\\u0301]",
|
| 180 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[nonreligi0us_suffix]",
|
| 181 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[nonreligiousness]",
|
| 182 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[ohne Religionsbuch]",
|
| 183 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[politically unaffiliated]",
|
| 184 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_no_broad_fuzzy_matching_or_attribution[prenom_nonreligi0us]",
|
| 185 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_other_failures_never_count_as_success[anonymization_inference_failed-blocked_model_runtime]",
|
| 186 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_other_failures_never_count_as_success[anonymization_text_limit-other_incomplete]",
|
| 187 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_other_failures_never_count_as_success[incomplete_ocr_document-other_incomplete]",
|
| 188 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_partial_full_and_taxonomy_mismatch_are_distinct",
|
| 189 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_span_conflicts_are_abstentions_not_returned_leaks",
|
| 190 |
+
"ocr/tests/test_gate03e_outcomes_status.py::test_unexpected_runtime_failure_is_sanitized_and_abstains",
|
| 191 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_blocked_review_record_has_reason_and_original_but_no_candidate",
|
| 192 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_caller_reference_rejected_before_host_lookup[../private-file]",
|
| 193 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_caller_reference_rejected_before_host_lookup[https://example.invalid]",
|
| 194 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_caller_reference_rejected_before_host_lookup[reference3]",
|
| 195 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_caller_reference_rejected_before_host_lookup[xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]",
|
| 196 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_canonical_provenance_and_no_raw_config_metadata",
|
| 197 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_each_policy_change_changes_digest[ocr]",
|
| 198 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_each_policy_change_changes_digest[placeholders]",
|
| 199 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_each_policy_change_changes_digest[terms]",
|
| 200 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_each_policy_change_changes_digest[threshold]",
|
| 201 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_legacy_review_cannot_invent_configuration_or_accept_new_normalization",
|
| 202 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_modified_provenance_invalidates_new_approval",
|
| 203 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_old_candidate_serialization_and_approval_remain_valid",
|
| 204 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_has_classified_source_and_no_approval_power",
|
| 205 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[acl]",
|
| 206 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[candidate_missing]",
|
| 207 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[credentials]",
|
| 208 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[expired]",
|
| 209 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[public_environment]",
|
| 210 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_review_refuses_missing_authority[revoked]",
|
| 211 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[changed_class]",
|
| 212 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[changed_digest]",
|
| 213 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[changed_source]",
|
| 214 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[revoked_during_read]",
|
| 215 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[session_expired_during_read]",
|
| 216 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_integrity_and_current_authorization[url_reference]",
|
| 217 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_preserves_full_unicode[Gr\\xfc\\xdfe aus dem Testlabor]",
|
| 218 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_preserves_full_unicode[\\u0928\\u092e\\u0938\\u094d\\u0924\\u0947]",
|
| 219 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_private_source_preserves_full_unicode[\\u6f22\\u5b57\\U0001f642synthetic]",
|
| 220 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_replacement_version_and_normalization_configuration_are_committed",
|
| 221 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_revocation_while_loading_review_material_prevents_return",
|
| 222 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_runtime_configuration_drift_blocks_candidate",
|
| 223 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_source_reference_classification_cannot_be_downgraded",
|
| 224 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_unknown_recognizer_configuration_is_never_defaulted",
|
| 225 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_unsupported_or_forged_candidate_fails_closed[missing_provenance]",
|
| 226 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_unsupported_or_forged_candidate_fails_closed[model_mismatch]",
|
| 227 |
+
"ocr/tests/test_gate03e_provenance_review.py::test_unsupported_or_forged_candidate_fails_closed[unknown_version]",
|
| 228 |
+
"ocr/tests/test_gate03g_security_audit.py::test_corrected_completed_review_must_bind_original_ocr_evidence",
|
| 229 |
+
"ocr/tests/test_gate03g_security_audit.py::test_expiry_during_real_sqlite_lock_wait_does_not_consume",
|
| 230 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[approve-1]",
|
| 231 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[approve-False]",
|
| 232 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[approve-None]",
|
| 233 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[approve-allow]",
|
| 234 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[approve-policy4]",
|
| 235 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[export-1]",
|
| 236 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[export-False]",
|
| 237 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[export-None]",
|
| 238 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[export-allow]",
|
| 239 |
+
"ocr/tests/test_gate03g_security_audit.py::test_legacy_policy_is_explicit_literal_true_and_current[export-policy4]",
|
| 240 |
+
"ocr/tests/test_gate03g_security_audit.py::test_missing_host_legacy_policy_denies_existing_approval",
|
| 241 |
+
"ocr/tests/test_gate03g_security_audit.py::test_post_link_sync_failure_leaves_only_complete_private_artifact",
|
| 242 |
+
"ocr/tests/test_gate03g_security_audit.py::test_private_delivery_revocation_during_write_prevents_publication",
|
| 243 |
+
"ocr/tests/test_gate03g_security_audit.py::test_revocation_during_consumption_burns_approval_without_returning_output[acl]",
|
| 244 |
+
"ocr/tests/test_gate03g_security_audit.py::test_revocation_during_consumption_burns_approval_without_returning_output[legacy_policy]",
|
| 245 |
+
"ocr/tests/test_gate03g_security_audit.py::test_transfer_rejects_mismatched_completed_review_lookup",
|
| 246 |
+
"ocr/tests/test_gate03g_security_audit.py::test_uncorrected_release_requires_explicit_operator_policy",
|
| 247 |
+
"ocr/tests/test_gate03g_security_audit.py::test_version_coercion_does_not_bypass_corrected_default_deny[v1]",
|
| 248 |
+
"ocr/tests/test_gate03g_security_audit.py::test_version_coercion_does_not_bypass_corrected_default_deny[v2]",
|
| 249 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[bounds-v1]",
|
| 250 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[bounds-v2]",
|
| 251 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[bounds-v3]",
|
| 252 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[classification-v1]",
|
| 253 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[classification-v2]",
|
| 254 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[classification-v3]",
|
| 255 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[duplicate-v1]",
|
| 256 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[duplicate-v2]",
|
| 257 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[duplicate-v3]",
|
| 258 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[empty-v1]",
|
| 259 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[empty-v2]",
|
| 260 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_bad_typed_candidate_cannot_approve_or_export_and_does_not_consume[empty-v3]",
|
| 261 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_legacy_archive_unique_indices_are_not_forced_contiguous",
|
| 262 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_malformed_document_containers_and_classification_fail_closed[v1]",
|
| 263 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_malformed_document_containers_and_classification_fail_closed[v2]",
|
| 264 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_malformed_document_containers_and_classification_fail_closed[v3]",
|
| 265 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_original_review_poc_rejection[test_duplicate_page_manifest_should_be_rejected]",
|
| 266 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_original_review_poc_rejection[test_invalid_replacement_bounds_should_be_rejected_by_parser]",
|
| 267 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_and_correction_reject_corrupt_stored_material[foreign_page]",
|
| 268 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_and_correction_reject_corrupt_stored_material[upper_bound]",
|
| 269 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices0-v1]",
|
| 270 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices0-v2]",
|
| 271 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices0-v3]",
|
| 272 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices1-v1]",
|
| 273 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices1-v2]",
|
| 274 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices1-v3]",
|
| 275 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices2-v1]",
|
| 276 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices2-v2]",
|
| 277 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices2-v3]",
|
| 278 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices3-v1]",
|
| 279 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices3-v2]",
|
| 280 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_private_review_requires_exact_original_page_set[indices3-v3]",
|
| 281 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices0-v1]",
|
| 282 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices0-v2]",
|
| 283 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices0-v3]",
|
| 284 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices1-v1]",
|
| 285 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices1-v2]",
|
| 286 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices1-v3]",
|
| 287 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices2-v1]",
|
| 288 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices2-v2]",
|
| 289 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_schema_rejects_duplicate_and_empty_pages_all_entry_paths[indices2-v3]",
|
| 290 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_source_aware_upper_bound_and_original_coordinate_semantics[v1]",
|
| 291 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_source_aware_upper_bound_and_original_coordinate_semantics[v2]",
|
| 292 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_source_aware_upper_bound_and_original_coordinate_semantics[v3]",
|
| 293 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[-1-2-v1]",
|
| 294 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[-1-2-v2]",
|
| 295 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[-1-2-v3]",
|
| 296 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0--1-v1]",
|
| 297 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0--1-v2]",
|
| 298 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0--1-v3]",
|
| 299 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-0-v1]",
|
| 300 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-0-v2]",
|
| 301 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-0-v3]",
|
| 302 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2.0-v1]",
|
| 303 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2.0-v2]",
|
| 304 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2.0-v3]",
|
| 305 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_0-v1]",
|
| 306 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_0-v2]",
|
| 307 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_0-v3]",
|
| 308 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_1-v1]",
|
| 309 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_1-v2]",
|
| 310 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-2_1-v3]",
|
| 311 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-None-v1]",
|
| 312 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-None-v2]",
|
| 313 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-None-v3]",
|
| 314 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-True-v1]",
|
| 315 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-True-v2]",
|
| 316 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0-True-v3]",
|
| 317 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0.0-2-v1]",
|
| 318 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0.0-2-v2]",
|
| 319 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[0.0-2-v3]",
|
| 320 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[1-1-v1]",
|
| 321 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[1-1-v2]",
|
| 322 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[1-1-v3]",
|
| 323 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[2-1-v1]",
|
| 324 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[2-1-v2]",
|
| 325 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[2-1-v3]",
|
| 326 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[False-2-v1]",
|
| 327 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[False-2-v2]",
|
| 328 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[False-2-v3]",
|
| 329 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[None-2-v1]",
|
| 330 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[None-2-v2]",
|
| 331 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_strict_replacement_bounds_direct_json_and_digest[None-2-v3]",
|
| 332 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_v3_duplicate_pages_cannot_bypass_with_version_coercion[v1]",
|
| 333 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_v3_duplicate_pages_cannot_bypass_with_version_coercion[v2]",
|
| 334 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[0-v1]",
|
| 335 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[0-v2]",
|
| 336 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[0-v3]",
|
| 337 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[1-v1]",
|
| 338 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[1-v2]",
|
| 339 |
+
"ocr/tests/test_gate03h_candidate_integrity.py::test_valid_pre_fix_bytes_digests_and_indexing_remain_identical[1-v3]",
|
| 340 |
+
"ocr/tests/test_ingest.py::test_embedded_skip_and_force",
|
| 341 |
+
"ocr/tests/test_ingest.py::test_malformed[%PDF-broken-a.pdf]",
|
| 342 |
+
"ocr/tests/test_ingest.py::test_malformed[hello-a.png]",
|
| 343 |
+
"ocr/tests/test_ingest.py::test_malformed[hello-a.txt]",
|
| 344 |
+
"ocr/tests/test_ingest.py::test_signature_and_limits",
|
| 345 |
+
"ocr/tests/test_ingest.py::test_tiff_and_bomb",
|
| 346 |
+
"ocr/tests/test_ingest.py::test_twenty_page_mixed",
|
| 347 |
+
"ocr/tests/test_limits_provenance.py::test_missing_models_readiness_and_output",
|
| 348 |
+
"ocr/tests/test_limits_provenance.py::test_no_token_and_remote_unavailable",
|
| 349 |
+
"ocr/tests/test_limits_provenance.py::test_partial_order_and_logs",
|
| 350 |
+
"ocr/tests/test_limits_provenance.py::test_quality_evidence_and_handoff",
|
| 351 |
+
"ocr/tests/test_limits_provenance.py::test_timeout_and_result_capacity",
|
| 352 |
+
"ocr/tests/test_limits_provenance.py::test_upload_chunked_rate_options",
|
| 353 |
+
"ocr/tests/test_live.py::test_live_alias",
|
| 354 |
+
"ocr/tests/test_live.py::test_real_api_image_and_pdf",
|
| 355 |
+
"ocr/tests/test_live.py::test_real_cpu",
|
| 356 |
+
"ocr/tests/test_private_host_adapters.py::test_local_delivery_requires_separate_current_authorization_and_retains_classification",
|
| 357 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[acl_revoked]",
|
| 358 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[boolean]",
|
| 359 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[candidate_missing]",
|
| 360 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[destination_changed]",
|
| 361 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[destination_revoked]",
|
| 362 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[expired_identity]",
|
| 363 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[expired_review]",
|
| 364 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[incomplete_review]",
|
| 365 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[requested_identity]",
|
| 366 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[revoked_review]",
|
| 367 |
+
"ocr/tests/test_private_host_adapters.py::test_real_adapter_contract_fails_closed_without_trusting_request_flags[revoked_session]",
|
| 368 |
+
"ocr/tests/test_private_host_adapters.py::test_review_evidence_is_immutable_and_candidate_bound",
|
| 369 |
+
"ocr/tests/test_private_host_adapters.py::test_revocation_after_export_still_blocks_private_delivery[acl_revoked]",
|
| 370 |
+
"ocr/tests/test_private_host_adapters.py::test_revocation_after_export_still_blocks_private_delivery[export_expired]",
|
| 371 |
+
"ocr/tests/test_private_host_adapters.py::test_revocation_after_export_still_blocks_private_delivery[export_revoked]",
|
| 372 |
+
"ocr/tests/test_private_host_adapters.py::test_revocation_after_export_still_blocks_private_delivery[review_expired]",
|
| 373 |
+
"ocr/tests/test_private_host_adapters.py::test_revocation_after_export_still_blocks_private_delivery[review_revoked]",
|
| 374 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[changed_bytes]",
|
| 375 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[extra_raw_metadata]",
|
| 376 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[public_fallback]",
|
| 377 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[redirect]",
|
| 378 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[substituted_directory]",
|
| 379 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[symlink]",
|
| 380 |
+
"ocr/tests/test_private_host_adapters.py::test_transport_prohibits_substitution_fallback_and_unregistered_artifacts[url]",
|
| 381 |
+
"ocr/tests/test_quality.py::test_blur_proxy_and_blank",
|
| 382 |
+
"ocr/tests/test_religious_terminology.py::test_casefold_never_matches_partial_expansion",
|
| 383 |
+
"ocr/tests/test_religious_terminology.py::test_custom_operator_terminology_uses_same_provenance_validation",
|
| 384 |
+
"ocr/tests/test_religious_terminology.py::test_engine_keeps_d2_and_review_flags_with_only_terminology_evidence",
|
| 385 |
+
"ocr/tests/test_religious_terminology.py::test_existing_first_result_refuses_before_initializing_model",
|
| 386 |
+
"ocr/tests/test_religious_terminology.py::test_historical_frozen_holdout_is_unchanged_well_formed_and_complete",
|
| 387 |
+
"ocr/tests/test_religious_terminology.py::test_length_limit_and_default_is_not_affiliation_inference",
|
| 388 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[A lecture on Taoism, not a personal assertion.-terms3-expected3]",
|
| 389 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[An agnos-\\ntisch entry.-terms8-expected8]",
|
| 390 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Angabe: OHNE KONFESSION-terms1-expected1]",
|
| 391 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Buddh1sm is an OCR error-terms12-expected12]",
|
| 392 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[He quoted \"Zoroastrianism\".-terms5-expected5]",
|
| 393 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Hinduism Hinduism-terms9-expected9]",
|
| 394 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Islamabad Islamismus preIslam Islam_suffix Islam\\u0301-terms10-expected10]",
|
| 395 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[No religious affiliation was stated.-terms2-expected2]",
|
| 396 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Pr\\xe9face: Stra\\xdfe / STRASSE.-terms6-expected6]",
|
| 397 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[She denied being a Jain.-terms4-expected4]",
|
| 398 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[Status: religionslos.-terms0-expected0]",
|
| 399 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[\\U0001f4a1 A Bah\\xe1'\\xed reference.-terms7-expected7]",
|
| 400 |
+
"ocr/tests/test_religious_terminology.py::test_literal_offsets_and_no_attribution[orthodox sorting; catholic taste; evangelical marketing-terms11-expected11]",
|
| 401 |
+
"ocr/tests/test_religious_terminology.py::test_longest_containing_phrase_preserves_offsets_and_has_no_fake_score",
|
| 402 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[Islam]",
|
| 403 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms0]",
|
| 404 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms10]",
|
| 405 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms1]",
|
| 406 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms2]",
|
| 407 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms3]",
|
| 408 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms4]",
|
| 409 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms5]",
|
| 410 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms6]",
|
| 411 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms7]",
|
| 412 |
+
"ocr/tests/test_religious_terminology.py::test_reject_unbounded_or_nonliteral_configuration[terms8]",
|
| 413 |
+
"ocr/tests/test_religious_terminology.py::test_terminology_does_not_hide_model_failure_or_overlap",
|
| 414 |
+
"ocr/tests/test_reviewed_corrections.py::test_all_five_supported_corrections[boundary]",
|
| 415 |
+
"ocr/tests/test_reviewed_corrections.py::test_all_five_supported_corrections[category]",
|
| 416 |
+
"ocr/tests/test_reviewed_corrections.py::test_all_five_supported_corrections[conflict]",
|
| 417 |
+
"ocr/tests/test_reviewed_corrections.py::test_all_five_supported_corrections[false_positive]",
|
| 418 |
+
"ocr/tests/test_reviewed_corrections.py::test_all_five_supported_corrections[missing]",
|
| 419 |
+
"ocr/tests/test_reviewed_corrections.py::test_approval_cannot_follow_correction_and_old_history_remains_valid[D1]",
|
| 420 |
+
"ocr/tests/test_reviewed_corrections.py::test_approval_cannot_follow_correction_and_old_history_remains_valid[D2]",
|
| 421 |
+
"ocr/tests/test_reviewed_corrections.py::test_cannot_reuse_span_on_other_document_even_same_text",
|
| 422 |
+
"ocr/tests/test_reviewed_corrections.py::test_conflicting_or_silent_overrides_fail_closed[conflict_not_all_targets]",
|
| 423 |
+
"ocr/tests/test_reviewed_corrections.py::test_conflicting_or_silent_overrides_fail_closed[overlapping_edits]",
|
| 424 |
+
"ocr/tests/test_reviewed_corrections.py::test_conflicting_or_silent_overrides_fail_closed[unacknowledged_prediction]",
|
| 425 |
+
"ocr/tests/test_reviewed_corrections.py::test_conflicting_or_silent_overrides_fail_closed[unknown_targets]",
|
| 426 |
+
"ocr/tests/test_reviewed_corrections.py::test_corrected_policy_revocation_is_checked_again_at_export",
|
| 427 |
+
"ocr/tests/test_reviewed_corrections.py::test_corrupt_private_records_fail_sanitized[classification]",
|
| 428 |
+
"ocr/tests/test_reviewed_corrections.py::test_corrupt_private_records_fail_sanitized[malformed]",
|
| 429 |
+
"ocr/tests/test_reviewed_corrections.py::test_corrupt_private_records_fail_sanitized[observation]",
|
| 430 |
+
"ocr/tests/test_reviewed_corrections.py::test_corrupt_private_records_fail_sanitized[source]",
|
| 431 |
+
"ocr/tests/test_reviewed_corrections.py::test_current_permission_and_storage_rechecked_at_publication[acl_at_commit]",
|
| 432 |
+
"ocr/tests/test_reviewed_corrections.py::test_current_permission_and_storage_rechecked_at_publication[expired_session]",
|
| 433 |
+
"ocr/tests/test_reviewed_corrections.py::test_current_permission_and_storage_rechecked_at_publication[missing_storage]",
|
| 434 |
+
"ocr/tests/test_reviewed_corrections.py::test_current_permission_and_storage_rechecked_at_publication[revoked_proposal]",
|
| 435 |
+
"ocr/tests/test_reviewed_corrections.py::test_current_permission_and_storage_rechecked_at_publication[revoked_view]",
|
| 436 |
+
"ocr/tests/test_reviewed_corrections.py::test_empty_prediction_warning_survives_manual_correction",
|
| 437 |
+
"ocr/tests/test_reviewed_corrections.py::test_human_tags_cannot_be_disguised_as_legacy_candidate[v1]",
|
| 438 |
+
"ocr/tests/test_reviewed_corrections.py::test_human_tags_cannot_be_disguised_as_legacy_candidate[v2]",
|
| 439 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[digest]",
|
| 440 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[end]",
|
| 441 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[extra_replacement]",
|
| 442 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[page]",
|
| 443 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[raw_text]",
|
| 444 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[start]",
|
| 445 |
+
"ocr/tests/test_reviewed_corrections.py::test_incorrect_original_offsets_and_malformed_edits_are_sanitized[wrong_target]",
|
| 446 |
+
"ocr/tests/test_reviewed_corrections.py::test_lost_publication_reply_retains_immutable_recovery_pointer",
|
| 447 |
+
"ocr/tests/test_reviewed_corrections.py::test_missing_explicit_corrected_release_policy_denies",
|
| 448 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_correction_permission_even_with_review_and_release_roles[apply]",
|
| 449 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_correction_permission_even_with_review_and_release_roles[bind]",
|
| 450 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_correction_permission_even_with_review_and_release_roles[inspect]",
|
| 451 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_correction_permission_even_with_review_and_release_roles[inspect_proposal]",
|
| 452 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_correction_permission_even_with_review_and_release_roles[submit]",
|
| 453 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_request_supplied_identity_or_approval_flag[False]",
|
| 454 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_request_supplied_identity_or_approval_flag[None]",
|
| 455 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_request_supplied_identity_or_approval_flag[credential2]",
|
| 456 |
+
"ocr/tests/test_reviewed_corrections.py::test_no_request_supplied_identity_or_approval_flag[synthetic-author]",
|
| 457 |
+
"ocr/tests/test_reviewed_corrections.py::test_runtime_failure_is_not_a_correctable_conflict",
|
| 458 |
+
"ocr/tests/test_reviewed_corrections.py::test_second_correction_uses_original_offsets_and_retains_chain",
|
| 459 |
+
"ocr/tests/test_reviewed_corrections.py::test_simultaneous_apply_and_proposal_replay_publish_once",
|
| 460 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[author_subject-another-author]",
|
| 461 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[context_digest-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff]",
|
| 462 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[evidence_digest-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff]",
|
| 463 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[expires_at-1]",
|
| 464 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[machine_snapshot_digest-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff]",
|
| 465 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_or_inconsistent_proposal_rejected[prior_candidate_digest-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff]",
|
| 466 |
+
"ocr/tests/test_reviewed_corrections.py::test_stale_parallel_proposal_cannot_discard_a_newer_correction",
|
| 467 |
+
"ocr/tests/test_reviewed_corrections.py::test_unicode_multipage_corrections_use_original_codepoints[0]",
|
| 468 |
+
"ocr/tests/test_reviewed_corrections.py::test_unicode_multipage_corrections_use_original_codepoints[1]",
|
| 469 |
+
"ocr/tests/test_reviewed_corrections.py::test_unnecessary_empty_correction_has_no_new_candidate_or_approval",
|
| 470 |
+
"ocr/tests/test_security_routing.py::test_bounded_retries_breaker[429]",
|
| 471 |
+
"ocr/tests/test_security_routing.py::test_bounded_retries_breaker[502]",
|
| 472 |
+
"ocr/tests/test_security_routing.py::test_bounded_retries_breaker[503]",
|
| 473 |
+
"ocr/tests/test_security_routing.py::test_budget_health_and_gpu_fallback",
|
| 474 |
+
"ocr/tests/test_security_routing.py::test_d2_hidden_substitution_blocked",
|
| 475 |
+
"ocr/tests/test_security_routing.py::test_empty_reply_one_attempt_and_no_false_success",
|
| 476 |
+
"ocr/tests/test_security_routing.py::test_explicit_no_fallback[cpu_only]",
|
| 477 |
+
"ocr/tests/test_security_routing.py::test_explicit_no_fallback[gpu_only]",
|
| 478 |
+
"ocr/tests/test_security_routing.py::test_fallback_and_throughput_gates",
|
| 479 |
+
"ocr/tests/test_security_routing.py::test_gpu_only_not_blocked_by_cpu_health",
|
| 480 |
+
"ocr/tests/test_security_routing.py::test_low_confidence_escalation",
|
| 481 |
+
"ocr/tests/test_security_routing.py::test_no_downgrade",
|
| 482 |
+
"ocr/tests/test_security_routing.py::test_queue_capacity_and_cancel",
|
| 483 |
+
"ocr/tests/test_security_routing.py::test_retry_policy_rechecked",
|
| 484 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-False-D0]",
|
| 485 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-False-D1]",
|
| 486 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-False-D2]",
|
| 487 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-True-D0]",
|
| 488 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-True-D1]",
|
| 489 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[cpu-True-D2]",
|
| 490 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[gpu-True-D0]",
|
| 491 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[gpu-True-D1]",
|
| 492 |
+
"ocr/tests/test_security_routing.py::test_security_matrix[gpu-True-D2]"
|
| 493 |
+
]
|
Agent.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## 1. Deployment Configuration
|
| 2 |
+
|
| 3 |
+
### Target Space
|
| 4 |
+
- **Profile:** `Leon4gr45`
|
| 5 |
+
- **Space:** `agent-0`
|
| 6 |
+
- **Full Identifier:** `Leon4gr45/agent-0`
|
| 7 |
+
- **Frontend Port:** `7860` (mandatory for all Hugging Face Spaces)
|
| 8 |
+
|
| 9 |
+
### Deployment Method
|
| 10 |
+
Choose the correct SDK based on the app type based on the codebase language:
|
| 11 |
+
|
| 12 |
+
- **Gradio SDK** — for Gradio applications
|
| 13 |
+
- **Streamlit SDK** — for Streamlit applications
|
| 14 |
+
- **Docker SDK** — for all other applications (recommended default for flexibility)
|
| 15 |
+
|
| 16 |
+
### HF Token
|
| 17 |
+
- The environment variable **`$HF_TOKEN` will always be provided at execution time**.
|
| 18 |
+
- Never hardcode the token. Always read it from the environment.
|
| 19 |
+
- All monitoring and log‑streaming commands rely on `$HF_TOKEN`.
|
| 20 |
+
|
| 21 |
+
### Required Files
|
| 22 |
+
- `Dockerfile` (or `app.py` for Gradio/Streamlit SDKs)
|
| 23 |
+
- `README.md` with Hugging Face YAML frontmatter:
|
| 24 |
+
```yaml
|
| 25 |
+
---
|
| 26 |
+
title: <APP NAME>
|
| 27 |
+
sdk: docker | gradio | streamlit
|
| 28 |
+
app_port: 7860
|
| 29 |
+
---
|
| 30 |
+
```
|
| 31 |
+
- `.hfignore` to exclude unnecessary files
|
| 32 |
+
- This `Agent.md` file (must be committed before deployment)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 2. API Exposure and Documentation
|
| 37 |
+
|
| 38 |
+
### Mandatory Endpoints
|
| 39 |
+
Every deployment **must** expose:
|
| 40 |
+
|
| 41 |
+
- **`/health`**
|
| 42 |
+
- Returns HTTP 200 when the app is ready.
|
| 43 |
+
- Required for Hugging Face to transition the Space from *starting* → *running*.
|
| 44 |
+
|
| 45 |
+
- **`/api-docs`**
|
| 46 |
+
- Documents **all** available API endpoints.
|
| 47 |
+
- Must be reachable at:
|
| 48 |
+
`https://HF_PROFILE-agent-0.hf.space/api-docs`
|
| 49 |
+
|
| 50 |
+
### Functional Endpoints
|
| 51 |
+
|
| 52 |
+
### /health
|
| 53 |
+
- Method: GET
|
| 54 |
+
- Purpose: Health Check
|
| 55 |
+
|
| 56 |
+
### /readiness
|
| 57 |
+
- Method: GET
|
| 58 |
+
- Purpose: Readiness Check
|
| 59 |
+
|
| 60 |
+
### /pipelines/run
|
| 61 |
+
- Method: POST
|
| 62 |
+
- Purpose: Queue a dataset generation pipeline for execution.
|
| 63 |
+
|
| 64 |
+
### /jobs
|
| 65 |
+
- Method: GET
|
| 66 |
+
- Purpose: List jobs from HF_WORK_REPO
|
| 67 |
+
|
| 68 |
+
### /jobs/{job_id}/manifest
|
| 69 |
+
- Method: GET
|
| 70 |
+
- Purpose: Inspect job manifest
|
| 71 |
+
|
| 72 |
+
### /jobs/{job_id}/checkpoints
|
| 73 |
+
- Method: GET
|
| 74 |
+
- Purpose: Inspect job checkpoints
|
| 75 |
+
|
| 76 |
+
### /jobs/{job_id}/outputs
|
| 77 |
+
- Method: GET
|
| 78 |
+
- Purpose: Inspect job output references
|
| 79 |
+
|
| 80 |
+
### /jobs/{job_id}/resume
|
| 81 |
+
- Method: POST
|
| 82 |
+
- Purpose: Explicitly resume a job from remote state
|
| 83 |
+
|
| 84 |
+
### /jobs/{job_id}/fork
|
| 85 |
+
- Method: POST
|
| 86 |
+
- Purpose: Fork an existing job run into a new durable job
|
| 87 |
+
|
| 88 |
+
### /pipelines/available
|
| 89 |
+
- Method: GET
|
| 90 |
+
- Purpose: Get available pipeline aliases from super_config.yaml.
|
| 91 |
+
|
| 92 |
+
### /configs/aliases
|
| 93 |
+
- Method: GET
|
| 94 |
+
- Purpose: Get available config file aliases from super_config.yaml.
|
| 95 |
+
|
| 96 |
+
### /tasks/queue
|
| 97 |
+
- Method: GET
|
| 98 |
+
- Purpose: Get lists of pending and scheduled tasks.
|
| 99 |
+
|
| 100 |
+
### /tasks/{task_id}/status
|
| 101 |
+
- Method: GET
|
| 102 |
+
- Purpose: Get the status of a pipeline run.
|
| 103 |
+
|
| 104 |
+
### /tasks/{task_id}/parameters
|
| 105 |
+
- Method: GET
|
| 106 |
+
- Purpose: Get the parameters a task was executed with.
|
| 107 |
+
|
| 108 |
+
### /tasks/{task_id}/interrupt
|
| 109 |
+
- Method: POST
|
| 110 |
+
- Purpose: Interrupt a running pipeline task subprocess or revoke a pending task.
|
| 111 |
+
|
| 112 |
+
### /tasks/{task_id}/logs
|
| 113 |
+
- Method: GET
|
| 114 |
+
- Purpose: Get logs for a specific task.
|
| 115 |
+
|
| 116 |
+
### /logs
|
| 117 |
+
- Method: DELETE
|
| 118 |
+
- Purpose: Clear all task log files.
|
| 119 |
+
|
| 120 |
+
### /tasks/{task_id}/outputs/download
|
| 121 |
+
- Method: GET
|
| 122 |
+
- Purpose: Download output directory for a task.
|
| 123 |
+
|
| 124 |
+
### /outputs/structure/{relative_path}
|
| 125 |
+
- Method: GET
|
| 126 |
+
- Purpose: Get structure of a path within the outputs directory.
|
| 127 |
+
|
| 128 |
+
### /inputs/structure/{relative_path}
|
| 129 |
+
- Method: GET
|
| 130 |
+
- Purpose: Get structure of a path within the inputs directory.
|
| 131 |
+
|
| 132 |
+
### /configs/structure/{relative_path}
|
| 133 |
+
- Method: GET
|
| 134 |
+
- Purpose: Get structure of a path within the configs directory.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 3. Deployment Workflow
|
| 139 |
+
|
| 140 |
+
Precondition: Use the huggingface hub cli hf to check that the space is empty of files nd delete any which are still in there and not belonging to the project to be uploaded
|
| 141 |
+
|
| 142 |
+
### Standard Deployment Command
|
| 143 |
+
After any code change, run:
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
hf upload Leon4gr45/agent-0 --repo-type=space
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
Scan build and run logs # Get build logs (SSE) curl -N -H "Authorization: Bearer $HF_TOKEN" "https://huggingface.co/api/spaces/Leon4gr45/agent-0/logs/build"
|
| 150 |
+
|
| 151 |
+
Get run logs (SSE) once the build logs succeed
|
| 152 |
+
curl -N -H "Authorization: Bearer $HF_TOKEN" "https://huggingface.co/api/spaces/Leon4gr45/agent-0/logs/run"
|
| 153 |
+
|
| 154 |
+
after 300 seconds to see if the deployment has been successful, and if not, fix the errors of deployment, and redeploy and monitor in a cycle until the space is running and reacts to the api endpoints you created.
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
FROM python:3.11.17-slim-bookworm
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=1 \
|
| 4 |
+
OCR_MODELS_DIR=/opt/ocr-models HOME=/home/ocr
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
| 6 |
+
ARG OCR_SOURCE_REVISION=unknown
|
| 7 |
+
ENV OCR_SOURCE_REVISION=$OCR_SOURCE_REVISION
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
# Build from repository root. Only OCR dependencies and code enter this separate worker.
|
| 10 |
+
COPY ocr/requirements.lock /tmp/requirements.lock
|
| 11 |
+
RUN --mount=type=secret,id=proxy_ca \
|
| 12 |
+
if [ -f /run/secrets/proxy_ca ]; then export PIP_CERT=/run/secrets/proxy_ca; fi; \
|
| 13 |
+
pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock
|
| 14 |
+
COPY ocr /app/ocr
|
| 15 |
+
# Download once at build time, verify pinned SHA256s, keep inference offline.
|
| 16 |
+
RUN --mount=type=secret,id=proxy_ca \
|
| 17 |
+
if [ -f /run/secrets/proxy_ca ]; then export SSL_CERT_FILE=/run/secrets/proxy_ca; fi; \
|
| 18 |
+
python -m ocr.download_models
|
| 19 |
+
RUN useradd --uid 1000 --create-home ocr && chmod -R a+rX /opt/ocr-models /app
|
| 20 |
+
USER ocr
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health',timeout=3)"
|
| 23 |
+
CMD ["uvicorn", "ocr.worker_api:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--no-access-log", "--limit-concurrency", "4"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Evan Armstrong
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,13 +1,287 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: pink
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
-
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: agent-0
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
---
|
| 6 |
|
| 7 |
+
# Augmentoolkit - Data for Domain-expert AI
|
| 8 |
+
Augmentoolkit creates domain-expert datasets that update an AI's brain (basically, its knowledge cutoff), so that the AI becomes an expert in an area of your choosing.
|
| 9 |
+
|
| 10 |
+
You upload documents, and press a button. And get a fully trained custom LLM. Now every aspect of your AI's behavior and understanding is under your control. Better still, Augmentoolkit **optionally works offline on your computer** -- no external API key required* for datagen† on most hardware.
|
| 11 |
+
|
| 12 |
+
Maybe you want AI to know the latest research papers in your field, or perhaps you want an LLM that understands your passion deeply and has learned from the same sources as you. Possibly, you dream of creating a lore expert for your favorite obscure fictional universe. Whatever the application is, Augmentoolkit lets you take text and make an LLM's brain inherently learn the information contained within. It also automatically creates a RAG-ready dataset (and can start up an inference server) if you want some traditional grounding as well.
|
| 13 |
+
|
| 14 |
+
Get started now (the interface will guide you through generating your first dataset):
|
| 15 |
+
|
| 16 |
+
(Be sure to use Python 3.11 when creating the virtual environment to be sure this'll work)
|
| 17 |
+
|
| 18 |
+
### MacOS (interface)
|
| 19 |
+
```bash
|
| 20 |
+
git clone https://github.com/e-p-armstrong/augmentoolkit.git
|
| 21 |
+
cd augmentoolkit # Python == 3.11
|
| 22 |
+
bash macos.sh # NOTE: Will attempt to install valkey via brew if not found.
|
| 23 |
+
# bash local_macos.sh # use this command if doing local dataset generation
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
### Linux (interface)
|
| 27 |
+
```bash
|
| 28 |
+
git clone https://github.com/e-p-armstrong/augmentoolkit.git
|
| 29 |
+
cd augmentoolkit # Python == 3.11
|
| 30 |
+
bash linux.sh # NOTE: will build Valkey from source if a Redis/Valkey server is not running
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
**Or for local inference**
|
| 34 |
+
```bash
|
| 35 |
+
git clone https://github.com/e-p-armstrong/augmentoolkit.git
|
| 36 |
+
cd augmentoolkit # Python == 3.11
|
| 37 |
+
bash local_linux.sh normal # or you can write "small" or a custom model name to serve the quantized version (for more consumer hardware) or a model of your choice, respectively. See the quickstart page linked just a bit farther down for a full reference here.
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
If you have multiple GPUs, run `local_linux.sh` with the `--tensor-parallelism N` argument. N == number of GPUs you have (even). So: 1, 2, 4, 8... etc.
|
| 41 |
+
|
| 42 |
+
> [!IMPORTANT]
|
| 43 |
+
>
|
| 44 |
+
> Please star the repo.
|
| 45 |
+
|
| 46 |
+
### Windows (interface)
|
| 47 |
+
> [!NOTE]
|
| 48 |
+
>
|
| 49 |
+
> If you're on windows, your best bet is to use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). [The CLI is easier to get running on windows honestly.](docs/quickstart.md#windows-cli)
|
| 50 |
+
|
| 51 |
+
<sub>*Note that datagen can take a while on a lot of hardware however, don't expect fast datagen on an old mac for instance. And for training you will need either a powerful machine of your own, or to rent (latter is done automatically for you if you so choose).</sub>
|
| 52 |
+
|
| 53 |
+
<sub>†If you want data to generate faster you *can* use an open-source LLM API, and the quickstart encourages you to. In addition to its custom dataset generation model, Augmentoolkit is optimized for open source LLMs like Deepseek or Llama.</sub>
|
| 54 |
+
|
| 55 |
+

|
| 56 |
+
|
| 57 |
+
Augmentoolkit, now that it is on its 3.0 version, has been refined and improved through over a year of professional application and experimentation. It is now the best way in the world to create domain expert LLMs, and it's MIT licensed.
|
| 58 |
+
|
| 59 |
+
If you use this project and like it, please consider starring the repo! It's also designed to be extremely customizable so consider **forking** Augmentoolkit!
|
| 60 |
+
|
| 61 |
+
> [!IMPORTANT]
|
| 62 |
+
>
|
| 63 |
+
> The below links contain very useful information. There is a table of contents, that links to extensive documentation pages for any conceivable part of the project, a bit further down.
|
| 64 |
+
|
| 65 |
+
[Help Videos](#video-tutorials) I walk through how to do all the cool stuff in this project starting from scratch, including training LLMs with the data and configs you get (takes 10 minutes). Check out the help videos if you want further guidance!
|
| 66 |
+
|
| 67 |
+
[Community](https://discord.gg/s6PBfsaVzu) If you have questions, if you are training models, if you are building cool new pipelines or extensions on top of Augmentoolkit's code, or if just want to hang out, I'd love to see you on the Augmentoolkit discord! It's also a good place to reach me.
|
| 68 |
+
|
| 69 |
+
[Newsletter](https://promptingweekly.substack.com/) I write about model training and data generation over on a Substack. Totally free, I just want to help people be able to use the tool better.
|
| 70 |
+
|
| 71 |
+
[Contact](#contact) I'm doing all kinds of things around this project, if you're interested in the mission and the business of bringing custom, personally-aligned AI to everyone, let's get in touch!
|
| 72 |
+
|
| 73 |
+
[Build](docs/pipeline_primer.md) Augmentoolkit is meant to be the go-to tool for people experimenting with training LLMs, whether they're a hobbyist or a professional. To that end, building new pipelines is as simple as writing Python functions (while adhering to about 2 mostly optional conventions). Efficient explainers and pipeline templates are provided for you to build your own dataset generation pipelines, and by extension, your own datasets and your own completely custom LLMs.
|
| 74 |
+
|
| 75 |
+
All configs are fully annotated with comments and placeholders to help you understand them as you fill them out.
|
| 76 |
+
|
| 77 |
+
## Documentation Pages
|
| 78 |
+
|
| 79 |
+
> [!NOTE]
|
| 80 |
+
>
|
| 81 |
+
> that this documentation page (the main README) has an [important note](#note-for-when-you-start-training) about model training for facts that you should read regardless of your experience level.
|
| 82 |
+
|
| 83 |
+
1. [Quickstart](docs/quickstart.md)
|
| 84 |
+
- [CLI](docs/quickstart.md#the-cli)
|
| 85 |
+
- [Interface](docs/quickstart.md#macos-interface)
|
| 86 |
+
1. [Video Help](#video-tutorials) <!-- Pages still todo -->
|
| 87 |
+
- [Generate Data and Train an LLM! (12 mins)](#train-a-model-on-your-own-data-in-13-minutes)
|
| 88 |
+
- [Detailed exploration (Interface)](#interface-deep-dive)
|
| 89 |
+
- [Detailed exploration (CLI)](#cli-and-code-structure-deep-dive)
|
| 90 |
+
1. [Vision](docs/vision.md)
|
| 91 |
+
- [What is it? (Technical)](docs/vision.md#what-is-it-technical)
|
| 92 |
+
- [What is it? (General)](docs/vision.md#what-is-it-general)
|
| 93 |
+
- [Ideas Presented](docs/vision.md#ideas-presented-and-hypotheses)
|
| 94 |
+
- [Goals](docs/vision.md#goals)
|
| 95 |
+
1. Longstart (customization and development guide links)
|
| 96 |
+
- [Pipelines Available by Default]()
|
| 97 |
+
- [Compositions]()
|
| 98 |
+
- [Complete Factual Generation](docs/complete_factual_datagen.md)
|
| 99 |
+
- [Meta Datagen](docs/meta.md)
|
| 100 |
+
- [Pipelines]()
|
| 101 |
+
- [Multi-Source Recall Factual Datagen](docs/multi_source_facts.md)
|
| 102 |
+
- [Single-Source Recall Factual Datagen](docs/single_source_recall.md)
|
| 103 |
+
- [Representation Variation](docs/representation_variation.md)
|
| 104 |
+
- [Traditional Classifier Bootstrapper](docs/classifier_bootstrapper.md)
|
| 105 |
+
- [Generic Data Rephrase](docs/generic_data_rephrase.md)
|
| 106 |
+
- [GRPO (experimental)](docs/grpo.md)
|
| 107 |
+
- [Correction Data (loss-masked mistakes)](docs/corrections.md)
|
| 108 |
+
- [Rag Data (preparing for enhanced recall)](docs/rag_data.md)
|
| 109 |
+
- [Debug (health check)](docs/debug.md)
|
| 110 |
+
- [Starting Point (build your own pipeline!)](docs/example.md)
|
| 111 |
+
- [RPToolkit](docs/rptoolkit.md)
|
| 112 |
+
- [Style and Persona Fine-Tuning Guide](docs/style_finetuning.md)
|
| 113 |
+
- [Utility]()
|
| 114 |
+
- [Basic LLM Server](docs/basic_server.md)
|
| 115 |
+
- [RAG LLM Server](docs/rag_server.md)
|
| 116 |
+
- **[Discord Hosting](docs/discord.md)**
|
| 117 |
+
- [Config Common Fields](docs/config_common_fields.md)
|
| 118 |
+
<!-- - [Training an LLM Walkthrough]() -->
|
| 119 |
+
- [Understand the Tool In Detail]()
|
| 120 |
+
- [Project Structure](docs/project_structure.md)
|
| 121 |
+
- [CLI]()
|
| 122 |
+
- [Flows](docs/CLI_flows.md)
|
| 123 |
+
- [Upload Documents](docs/CLI_flows.md#upload-documents)
|
| 124 |
+
- [Starting Runs](docs/CLI_flows.md#starting-runs)
|
| 125 |
+
- [Observing Runs](docs/CLI_flows.md#observing-results)
|
| 126 |
+
- [Getting Your Results Back](docs/CLI_flows.md#getting-your-results-back)
|
| 127 |
+
- [Interface]()
|
| 128 |
+
- [Flows](docs/interface_flows.md)
|
| 129 |
+
- [Upload Documents](docs/interface_flows.md#upload-documents)
|
| 130 |
+
- [Starting Runs](docs/interface_flows.md#starting-runs)
|
| 131 |
+
- [Observing Runs](docs/interface_flows.md#observing-runs)
|
| 132 |
+
- [Getting Your Results Back](docs/interface_flows.md#getting-your-results-back)
|
| 133 |
+
- [Training with Axolotl Concepts](docs/axolotl_concepts.md)
|
| 134 |
+
- [Customize and Develop]()
|
| 135 |
+
- [New Pipeline Primer](docs/pipeline_primer.md)
|
| 136 |
+
- [Abstractions Primer](docs/abstractions_primer.md)
|
| 137 |
+
- [Conventions Commandments](docs/pipeline_conventions.md)
|
| 138 |
+
- [Reminder That Conventions Are Minimal and You Can Just Code and It Will Probably Work](docs/conventions_reminder.md)
|
| 139 |
+
1. [Discord!](#discord)
|
| 140 |
+
1. [Updates and Training/Datagen Tips Blog! Stay in the loop!](#training-and-datagen-tips-blog)
|
| 141 |
+
1. [Contributing!](#contributing)
|
| 142 |
+
1. [Contact & Client Work](#contact)
|
| 143 |
+
|
| 144 |
+
**If you're familiar with LLMs and want a more jargonful rundown of what Augmentoolkit is and what makes it cool, check out [this section](docs/vision.md)**
|
| 145 |
+
|
| 146 |
+
Cite:
|
| 147 |
+
[](https://zenodo.org/doi/10.5281/zenodo.11525927)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
> [!NOTE]
|
| 151 |
+
>
|
| 152 |
+
> If you don't want to do model training, but just want to generate a dataset, turn `do_train` off in your dataset generation config.
|
| 153 |
+
|
| 154 |
+
### Video Tutorials
|
| 155 |
+
|
| 156 |
+
#### [Train a Model on your Own Data in 13 Minutes](https://youtu.be/E9TyyZzIMyY)
|
| 157 |
+
|
| 158 |
+
#### [Interface Deep Dive!](https://youtu.be/M-OFVwHPfeU)
|
| 159 |
+
|
| 160 |
+
#### [CLI and Code Structure Deep Dive!](https://youtu.be/cEkgw7sYqMw)
|
| 161 |
+
|
| 162 |
+
^ This one is useful if you're going to make modifications to the code
|
| 163 |
+
|
| 164 |
+
### Benefits
|
| 165 |
+
**Augmentoolkit makes LLM data easy.**
|
| 166 |
+
- **Cheap:** Augmentoolkit pipelines use open-source LLMs, and so can be run on consumer hardware for hardly any cost, or cheaply via APIs like Deepinfra *(the "local" prompt sets should enable usage of most pipelines by reasoning models, too)*
|
| 167 |
+
- **Effortless:** Any Augmentoolkit pipeline can be run with an intuitive interface that is started by running a start script. Alternatively, you can make data by putting some files in a folder, and then running a Python script. If that's too much, you can also use the graphical user interface, now a first-class citizen in Augmentoolkit 3 (and in fact, the recommended way to run Augmentoolkit). Previously-started runs are continued automatically, so you don't need to worry about interruptions costing you time and/or money.
|
| 168 |
+
- **Fast:** when using APIs, you can quickly generate millions of trainable tokens. Fully async code lets you get results quickly. Reading and chunking caches ensure that even large-scale workloads are quick to use. Models are automatically trained after the data is ready, and are even automatically downloaded and prepared for inference on your local machine. All the hard or annoying parts of the process have been automated and made efficient. In the past creating datasets and iterating and testing and learning could have taken a skilled person months; now, anyone can press a button, come back in a day, and chat with a newly-trained model.
|
| 169 |
+
- **Innovative, Effective Approach to Factual Training:** Augmentoolkit has a production-tested method of creating domain-expert LLMs that can understand entirely new subjects. Many separate pipelines are composed together to produce quality datasets that teach capabilities such as answering factual questions, acknowledging when something is not known by the model, correcting mistakes, etc. You can be confident in getting high-quality specialist models when you use Augmentoolkit.
|
| 170 |
+
|
| 171 |
+
We've also done our best to **facilitate the step after you generate your data -- training your LLM:**
|
| 172 |
+
- **Production-Scale:** Datasets that are gigabytes-large have been generated with Augmentoolkit -- it is battle-hardened, it works at scale without annoying inefficiencies costing immense time, and it is ready for the stresses of production.
|
| 173 |
+
- **Train an AI for the cost of a dinner:** you can generate data on your own hardware for what is basically free. Augmentoolkit can then automatically perform a full finetune of an AI, on your own data, for a tiny sum of money (roughly $20 for the finetuning part of the process).
|
| 174 |
+
- **Create your LLM in less than a day:** with a fully automated process for turning documents into datasets, and only a single button-click needed to kick off training, making a subject matter expert LLM is *fast* (especially when you use API for the dataset generation). Iterate quickly and cheaply.
|
| 175 |
+
- **When you use the same recipe, you get the same bread:** Augmentoolkit datasets have been used successfully for professional consulting projects. Video documentation is linked in this README that shows exactly how to use this tool to do the same. The code, settings, and prompts you need is all here. Examples, templates, comments, marked-out placeholders, and extensive documentation is all available.
|
| 176 |
+
- **Train AI with confidence, *especially* if it's your first time:** between the battle-tested process, extensive video docs, in-depth README, and Discord community, you can be confident you'll get a good LLM out of this.
|
| 177 |
+
|
| 178 |
+
**Do it all locally**
|
| 179 |
+
With a custom-trained 7b model built to run these pipelines specifically, Augmentoolkit can generate data on consumer hardware, and can do so at incredible scale, with incredible parallelism, when on higher-performance computers. Budget does not need to be a constraint -- just passion and time. Of course, if you want immediate results/speed, you can use an API too.
|
| 180 |
+
|
| 181 |
+
Finally, **using the model you create should be easy and valuable:**
|
| 182 |
+
- **AI that understands your facts:** For the professionals and the passionate: training an LLM with Augmentoolkit's Complete Factual Datagen "composition" pipeline creates an assistant that understands the big picture of the data you're training on. If RAG is like giving an LLM an open-book test on a textbook it hasn't read before, then training on Augmentoolkit data gives it some time to study before the test as well. This pipeline has been battle-tested in consulting projects across different industries. Compared to earlier versions of Augmentoolkit, Augmentoolkit's 3.0 version generates a wide variety of different domain data, and it even automatically balances this data with the generic data it uses.
|
| 183 |
+
- **Individual Alignment:** Use GPRO (the same algorithm that made Deepseek R1 as good as it is) to align a model to any task imaginable without modifying any code. Augmentoolkit adopts an innovative approach of letting you use an LLM as a reward function -- you write a prompt that grades certain outputs higher, and then those reward scores teach the model to behave more like that in the future. Want your model to do a task better? Explain what "better" is and then the model will learn it. Want your model to be more emotional and human-like? Explain how to grade responses based on their emotional content, and the model will [learn it](https://huggingface.co/Heralax/llama-gRPo-emotions-nothoughts). Want your model to write like a pirate? Explain in your grading prompt what makes a good pirate-like response, and the model will learn it. You can also change code and use traditional reward functions if you want to. The GRPO pipeline is experimental and in beta, but early results are promising.
|
| 184 |
+
- **Make sense of massive data without using human annotators:** For the heavy-duty ML professionals: if you have a large dataset with tons of unlabelled text (like the Enron emails dataset, IMDB, or fineweb, etc.) you can now write a sentence or two that describes two classes which exist in that data. Augmentoolkit's classifier creator pipeline will then use an LLM to make a full classification dataset, based on a subset of the input data and your specified classes; it'll then train a classifier and evaluate it and take more data and retrain, in a loop, until validation loss is below a specified threshold. Classifiers trained using this pipeline seem to achieve similar performance to classifiers trained on human-labelled data. Be advised that data is not yet automatically balanced between different labels.
|
| 185 |
+
- **AI inspired by your favorite fiction:** For the creatives and entertainers: using RPToolkit, you can create detailed and varied multi-turn roleplaying data with the themes of any story you can think of. If you're creating custom AI for creative or entertainment purposes, you can now specialize it in any genre you want. Want a depressing and dark specialist in mecha stories? Feed in some stories and you can get a ton of data for that. How about an AI writer of wholesome slice of life? You can get data for that too. Create as broad or as narrow of a writing AI as you want from whatever inspiration you can find.
|
| 186 |
+
|
| 187 |
+
*Clarification: Augmentoolkit, the project, has multiple pipelines: the original pipeline (QA), RPtoolkit (rich multiturn roleplaying data), and the classifier creator. If it is said that "Augmentoolkit can make [some kind of data]" then I mean that one of Augmentoolkit's pipelines can do so.*
|
| 188 |
+
|
| 189 |
+
### NOTE For When You Start Training
|
| 190 |
+
|
| 191 |
+
Factual finetuning requires a certain number of optimizer steps to stick. If training is where the LLM's brain "moves" towards a place where it understands your new domain, "optimizer steps" are the number of times the LLM moves. **If your dataset is small you may not have enough optimizer steps for the LLM to learn the new domain well.**
|
| 192 |
+
|
| 193 |
+
Because of this, ironically, it can be easier to teach LLMs large new domains rather than small ones, with training. However, there are tools at your disposal for turning a small dataset into a large one when you use Augmentoolkit.
|
| 194 |
+
|
| 195 |
+
In [complete factual dataset](docs/complete_factual_datagen.md) you have the `number_of_factual_sft_generations_to_do` setting for the whole pipeline, and the `variation_generation_counts` which you can customize per input dir. The one that is customized per dir makes the data from a specific input dir represented more in the continued pretraining data; the other setting increases the overall amount of SFT data made from all input dirs together. With these two levers you can make a small dataset as large as you need — though some of the data may be very similar, you can still scale it up in this way to teach it to an LLM without catastrophic drawbacks.
|
| 196 |
+
|
| 197 |
+
As a "break glass in case of emergency" option, if your dataset is exceptionally small, you may want to consider turning sample packing off. This an be done by modifying the pretrain and finetune kwargs to set sample packing off (do this in the complete factual datagen config).
|
| 198 |
+
|
| 199 |
+
```yaml
|
| 200 |
+
other_pretrain_kwargs: {sample_packing: False}
|
| 201 |
+
other_finetune_kwargs: {sample_packing: False}
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
Turning off sample packing has not been tested with the current iteration of Augmentoolkit's settings yet, so success with that emergency approach cannot be guaranteed for extremely small datasets, but since the main problem with extremely small datasets is a lack of per-epoch optimizer steps causing the LLM to not learn the data enough, *theoretically*, this should work.
|
| 205 |
+
|
| 206 |
+
Most of the configuration of Augmentoolkit you'll do, besides changing input/output paths for different models, is probably going to be related to the optimizer step. With very large input datasets you'll want to reduce things that increase the optimizer step because otherwise you'll be training for a long time, whereas with very small ones you'll have to pull out tricks to increase it. That's why this has a section and is marked out as important -- be cognizant of the size of your dataset as you create it!
|
| 207 |
+
|
| 208 |
+
If you have any questions about your specific use case, consider heading over to the [Discord](https://discord.gg/s6PBfsaVzu)
|
| 209 |
+
|
| 210 |
+
### Temporary Announcement if You've Been Here Before
|
| 211 |
+
If you're returning to look at this repo after a while, I want to make a few things clear!
|
| 212 |
+
|
| 213 |
+
Firstly, a *lot* has changed. I disappeared for six months, half of it was spent on research and the other half was spent building. I wanted the tool to fundamentally work better. Now, Augmentoolkit reliably produces great domain experts across datasets of different sizes. It can even teach a model something that it has not seen at all during pretraining. This experimentation cost thousands of dollars out of pocket, but I believe it was worth it, since anyone can now make domain experts about arbitrary subjects with very little technical experience.
|
| 214 |
+
|
| 215 |
+
Secondly, things are *much* easier to use. The interface is robust and is not a second-class citizen anymore. Start scripts, automatically generated and balanced training configs, better error messages, and a host of other improvements should make Augmentoolkit much nicer to use.
|
| 216 |
+
|
| 217 |
+
Not much code from the original survived, though making older pipelines fit into the project as it is now, is pretty simple ([see the new pipeline example and you'll get a picture of how they look like now](/docs/example.md)). Also, if you have custom prompts from before, they should work with the new pipelines without modification. The older pipeline compared to this one is like a rat compared to a human -- they're technically related and have a lot of the same DNA, but the human is much more evolved and more capable. I hope you enjoy using the new project and getting great results.
|
| 218 |
+
|
| 219 |
+
The bad news is that since so much has changed, some new bugs probably got introduced. Please report bugs so that they can be fixed. I had not been keeping too close an eye on the issues these past 4 months since the entire project was being ripped apart anyway -- now that it is in a more final form, and frankly now that I have better discipline with this sort of thing, I'll be focused on the Discord and GitHub issues to correct any mistakes that you point out. If any of the new documentation is unclear about parts of the project, please let me know. And, if you have [custom pipelines]() that you want to add, or bugfixes, please check out [Contributing]() and make a PR!
|
| 220 |
+
<!-- Not much survived from the previous version, this is like a modern ape observing a rat -- technically related, lots of the DNA is the same, but the ape is much more evolved and capable. Also since everything is differnt and the project is much larger some of the accumulated fixes will have gone away. Please report bugs so that they can be fixed. -->
|
| 221 |
+
|
| 222 |
+
### Discord
|
| 223 |
+
|
| 224 |
+
Custom-built models are (usually) not meant to be enjoyed only by their creators. There's a [new feature in Augmentoolkit](docs/discord.md) where you can easily make your custom models into Discord bots! Now you can share your custom AI creations with your friends or community! Also, all the code runs on your own computer so no worry about recurring costs.
|
| 225 |
+
|
| 226 |
+
Speaking of Discord...
|
| 227 |
+
|
| 228 |
+
Augmentoolkit is partly about democratizing dataset generation, so community is hugely important to the project! There's a Discord server where you can get help with custom model creation, as well as share new pipelines, prompt sets, or projects you're creating! [Come hang out and be part of a useful community of like-minded people!](https://discord.gg/s6PBfsaVzu)
|
| 229 |
+
|
| 230 |
+
### Training and Datagen Tips Blog
|
| 231 |
+
|
| 232 |
+
I write about model training and data generation over on a [free substack](https://promptingweekly.substack.com/)! If you want read access to my brain as I continue to experiment and explore with dataset generation, consider signing up to up your model creation game. If you're planning on building your own dataset generation pipelines using the tools and abstractions provided by Augmentoolkit, some of the advice there might be very useful.
|
| 233 |
+
|
| 234 |
+
Now that the new Augmentoolkit version is out, I finally have time to post again (and new ideas to post as well).
|
| 235 |
+
|
| 236 |
+
### Contributing!
|
| 237 |
+
|
| 238 |
+
PRs for bugfixes, new pipelines, and improvements are welcome! If you have an experiment you're proud of, consider opening a PR. The rules are pretty standard:
|
| 239 |
+
|
| 240 |
+
- Contributors can open PRs
|
| 241 |
+
- Collaborators can push to branches and merge PRs into the master branch
|
| 242 |
+
- Collaborators may either be chosen depending on contributions, or may be chosen internally within Augmentoolkit (the company)
|
| 243 |
+
- [The example pipeline and its documentation](docs/example.md) contain useful information for making your own pipeline. You are encouraged to fork Augmentoolkit and experiment!
|
| 244 |
+
- Code with the style you want, just test thoroughly before making a PR
|
| 245 |
+
- Caveat: failing silently or continuing is worse than explicitly erroring if an impossible state is reached
|
| 246 |
+
- Asserts are your friend
|
| 247 |
+
- `black .` makes even MY code look formatted nice, it can do the same for yours.
|
| 248 |
+
|
| 249 |
+
### Useful Commands for Datagen and Training Workflows
|
| 250 |
+
|
| 251 |
+
Copy-paste these when appropriate or use them as a reference.
|
| 252 |
+
|
| 253 |
+
Copy files over to a different computer (such as a GPU instance on runpod)
|
| 254 |
+
|
| 255 |
+
```
|
| 256 |
+
scp -P [port] -r ./outputs/your-output-dir/pretraining_run root@123.456.78.9:/workspace/axolotl
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
Kick off a training run:
|
| 260 |
+
```
|
| 261 |
+
accelerate launch -m axolotl.cli.train [your_config].yaml
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
Convert and quantize with llama.cpp
|
| 265 |
+
```
|
| 266 |
+
python ~/llama.cpp/convert_hf_to_gguf.py --outtype q8_0
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
## Contact!
|
| 270 |
+
|
| 271 |
+
- Email me at: evanpeterarmstrong@gmail.com (NOTE: my inbox is flooded, your message might not get through, prefer booking a call for serious discussions)
|
| 272 |
+
- [For serious and urgent discussions, we can schedule a call!](https://calendly.com/evanpeterarmstrong/30min)
|
| 273 |
+
- [I'm pretty active on the Augmentoolkit discord server and a bunch of other AI discords. Find me as @heralax!](https://discord.gg/s6PBfsaVzu)
|
| 274 |
+
- [I sometimes post stuff on X/Twitter](https://twitter.com/e_p_armstrong)
|
| 275 |
+
- [Substack! I am finally posting again.](https://promptingweekly.substack.com/)
|
| 276 |
+
- [YouTube -- The source of the help videos](https://www.youtube.com/@Heralax)
|
| 277 |
+
- [Let's connect on LinkedIn!](https://www.linkedin.com/in/evan-armstrong-1a84b3200/)
|
| 278 |
+
|
| 279 |
+
If you have a company or organization that wants to serve custom domain-expert AI to internal users (to get employees the information they need to do their jobs great) or to external users (for instance, to answer community questions or increase product awareness) then we should [get in touch](https://calendly.com/evanpeterarmstrong/discovery-call).
|
| 280 |
+
|
| 281 |
+
Also, if you have an **AI Chat Wrapper Startup or Company** that is currently being extorted by OpenAI, [we should also talk](https://calendly.com/evanpeterarmstrong/discovery-call), because I'm fairly certain **I can save you a ton on API costs while maintaining or improving answer quality**. Not only will I use this tool to produce a quality model, but I also have the means to run it at scale, which is not a trivial problem to solve.
|
| 282 |
+
|
| 283 |
+
The Augmentoolkit project is going to continue to be developed. It has been consistently developed for a long time -- the many-months gap from this major update to the previous one is because I was busy researching and developing the techniques, and later, preparing this release itself (this update has been in the works a long time). I am open to and would appreciate organizations sponsoring this open-source project -- I'd like nothing more than to research and build the tools for creating custom LLMs all day!
|
| 284 |
+
|
| 285 |
+
I am also working on ambitious commercial solutions involving Augmentoolkit and Augmentoolkit tech. This project is part of a larger master-plan. If you're an investor, I'm very open to discussions here! My [Calendly](https://calendly.com/evanpeterarmstrong/30min) is always open.
|
| 286 |
+
|
| 287 |
+
Current Generalized AI means hallucinations, platitudes, and sycophancy; domain-expert AI writes with your knowledge, understanding, and is aligned to **your** tastes. As more people use the same AI, more of the world sounds the same and thinks the same--it is my belief that individually-customized LLMs are the only way to avoid this world of slop.
|
api.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path as PyPath
|
| 5 |
+
import traceback
|
| 6 |
+
import yaml
|
| 7 |
+
import sys
|
| 8 |
+
import shutil
|
| 9 |
+
import zipfile
|
| 10 |
+
import httpx
|
| 11 |
+
import os
|
| 12 |
+
import tempfile
|
| 13 |
+
import time
|
| 14 |
+
import subprocess
|
| 15 |
+
|
| 16 |
+
from fastapi import (
|
| 17 |
+
FastAPI,
|
| 18 |
+
HTTPException,
|
| 19 |
+
UploadFile,
|
| 20 |
+
File,
|
| 21 |
+
BackgroundTasks,
|
| 22 |
+
Path as FastApiPath,
|
| 23 |
+
Query,
|
| 24 |
+
Body,
|
| 25 |
+
Request,
|
| 26 |
+
)
|
| 27 |
+
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse, RedirectResponse
|
| 28 |
+
from fastapi.staticfiles import StaticFiles
|
| 29 |
+
from typing import List, Optional, Dict, Any
|
| 30 |
+
from pydantic import BaseModel, Field
|
| 31 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 32 |
+
|
| 33 |
+
from huey_config import huey
|
| 34 |
+
from tasks import (
|
| 35 |
+
run_pipeline_task,
|
| 36 |
+
set_final_status,
|
| 37 |
+
resolve_job_output_dir,
|
| 38 |
+
)
|
| 39 |
+
from huey.exceptions import (
|
| 40 |
+
HueyException,
|
| 41 |
+
TaskException,
|
| 42 |
+
)
|
| 43 |
+
from redis_config import (
|
| 44 |
+
get_progress,
|
| 45 |
+
redis_client,
|
| 46 |
+
set_progress,
|
| 47 |
+
)
|
| 48 |
+
import signal
|
| 49 |
+
|
| 50 |
+
from resolve_path import resolve_path
|
| 51 |
+
|
| 52 |
+
from file_operation_helpers import (
|
| 53 |
+
get_safe_path,
|
| 54 |
+
zip_directory,
|
| 55 |
+
get_dir_structure,
|
| 56 |
+
FileStructure,
|
| 57 |
+
MoveItemRequest,
|
| 58 |
+
handle_get_structure,
|
| 59 |
+
handle_download_item,
|
| 60 |
+
handle_delete_item,
|
| 61 |
+
handle_move_item,
|
| 62 |
+
handle_create_directory,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
from augmentoolkit.hf_persistence import (
|
| 66 |
+
HFHubManager,
|
| 67 |
+
CheckpointManager,
|
| 68 |
+
JobManifestManager,
|
| 69 |
+
IncompatibleResumeError,
|
| 70 |
+
JobManifest,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
logging.basicConfig(level=logging.INFO)
|
| 75 |
+
logger = logging.getLogger(__name__)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
SUPER_CONFIG_PATH = PyPath("super_config.yaml")
|
| 79 |
+
PATH_ALIASES = {}
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
with open(SUPER_CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 83 |
+
super_config = yaml.safe_load(f)
|
| 84 |
+
PATH_ALIASES = super_config.get("path_aliases", {})
|
| 85 |
+
except FileNotFoundError:
|
| 86 |
+
print(f"ERROR: Super config file not found at {SUPER_CONFIG_PATH}.")
|
| 87 |
+
except yaml.YAMLError as e:
|
| 88 |
+
print(f"ERROR: Error parsing super config file {SUPER_CONFIG_PATH}: {e}")
|
| 89 |
+
|
| 90 |
+
INPUTS_DIR = PyPath("./inputs").resolve()
|
| 91 |
+
OUTPUTS_DIR = PyPath("./outputs").resolve()
|
| 92 |
+
GENERATION_DIR = PyPath("./generation").resolve()
|
| 93 |
+
CONFIGS_DIR = PyPath("./external_configs").resolve()
|
| 94 |
+
MAX_FILE_SIZE = 1024 * 1024 * 1000
|
| 95 |
+
|
| 96 |
+
LOGS_DIR = PyPath("./logs").resolve()
|
| 97 |
+
|
| 98 |
+
INPUTS_DIR.mkdir(exist_ok=True)
|
| 99 |
+
OUTPUTS_DIR.mkdir(exist_ok=True)
|
| 100 |
+
GENERATION_DIR.mkdir(exist_ok=True)
|
| 101 |
+
CONFIGS_DIR.mkdir(exist_ok=True)
|
| 102 |
+
LOGS_DIR.mkdir(exist_ok=True)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class PipelineRunRequest(BaseModel):
|
| 106 |
+
node_path: str
|
| 107 |
+
config_path: Optional[str] = None
|
| 108 |
+
parameters: Optional[Dict[str, Any]] = None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class PipelineRunResponse(BaseModel):
|
| 112 |
+
pipeline_id: str
|
| 113 |
+
message: str
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class PipelineStatus(str, Enum):
|
| 117 |
+
PENDING = "PENDING"
|
| 118 |
+
RUNNING = "RUNNING"
|
| 119 |
+
COMPLETED = "COMPLETED"
|
| 120 |
+
FAILED = "FAILED"
|
| 121 |
+
REVOKED = "REVOKED"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class PipelineStatusResponse(BaseModel):
|
| 125 |
+
task_id: str
|
| 126 |
+
status: PipelineStatus
|
| 127 |
+
message: Optional[str] = None
|
| 128 |
+
progress: Optional[float] = Field(None, ge=0.0, le=1.0)
|
| 129 |
+
details: Optional[Dict[str, Any]] = None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class QueueStatusResponse(BaseModel):
|
| 133 |
+
pending_tasks: List[str] = Field(..., description="List of task IDs currently pending execution.")
|
| 134 |
+
scheduled_tasks: List[str] = Field(..., description="List of task IDs scheduled for future execution.")
|
| 135 |
+
message: str
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class CreateDirectoryRequest(BaseModel):
|
| 139 |
+
relative_path: str = Field(..., description="The relative path within base directory.")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class DuplicateConfigRequest(BaseModel):
|
| 143 |
+
source_alias: str = Field(..., description="Alias from super_config.yaml.")
|
| 144 |
+
destination_relative_path: str = Field(..., description="Desired relative path.")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class TaskParametersResponse(BaseModel):
|
| 148 |
+
task_id: str
|
| 149 |
+
parameters: Dict[str, Any]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
app = FastAPI(
|
| 153 |
+
title="Augmentoolkit Resumable Dataset Factory API",
|
| 154 |
+
description="API for managing and running Augmentoolkit dataset pipelines with Hugging Face Hub remote persistence.",
|
| 155 |
+
version="2.0",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
origins = [
|
| 159 |
+
"http://localhost:5173",
|
| 160 |
+
"http://127.0.0.1:5173",
|
| 161 |
+
"http://localhost:5174",
|
| 162 |
+
"http://127.0.0.1:5174",
|
| 163 |
+
"http://localhost:3000",
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
app.add_middleware(
|
| 167 |
+
CORSMiddleware,
|
| 168 |
+
allow_origins=origins,
|
| 169 |
+
allow_credentials=True,
|
| 170 |
+
allow_methods=["*"],
|
| 171 |
+
allow_headers=["*"],
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@app.on_event("startup")
|
| 176 |
+
async def startup_event():
|
| 177 |
+
"""At startup, reconnects to HF_WORK_REPO, discovers incomplete jobs, reconciles state, and auto-resumes if enabled."""
|
| 178 |
+
logger.info("Augmentoolkit API starting up...")
|
| 179 |
+
work_repo = os.environ.get("HF_WORK_REPO")
|
| 180 |
+
if work_repo:
|
| 181 |
+
try:
|
| 182 |
+
ckpt_mgr = CheckpointManager()
|
| 183 |
+
incomplete_jobs = ckpt_mgr.discover_incomplete_jobs(work_repo=work_repo)
|
| 184 |
+
logger.info(f"Discovered {len(incomplete_jobs)} incomplete job(s) in HF_WORK_REPO '{work_repo}'.")
|
| 185 |
+
auto_resume = os.environ.get("HF_AUTO_RESUME", "false").lower() in ("true", "1", "yes")
|
| 186 |
+
if auto_resume:
|
| 187 |
+
for manifest in incomplete_jobs:
|
| 188 |
+
logger.info(f"Auto-resuming durable job {manifest.job_id}...")
|
| 189 |
+
run_pipeline_task(
|
| 190 |
+
node_path=manifest.pipeline,
|
| 191 |
+
parameters={
|
| 192 |
+
"job_id": manifest.job_id,
|
| 193 |
+
"hf_source_repo": manifest.source_repo,
|
| 194 |
+
"hf_work_repo": manifest.work_repo,
|
| 195 |
+
"hf_output_repo": manifest.output_repo,
|
| 196 |
+
"source_revision": manifest.source_revision,
|
| 197 |
+
},
|
| 198 |
+
)
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error(f"Error during startup HF recovery discovery: {e}")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@app.get("/health", summary="Health Check")
|
| 204 |
+
async def health_check():
|
| 205 |
+
"""Basic health check endpoint for Hugging Face."""
|
| 206 |
+
return {"status": "ok", "message": "Augmentoolkit API is running."}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.get("/readiness", summary="Readiness Check")
|
| 210 |
+
async def readiness_check():
|
| 211 |
+
"""
|
| 212 |
+
Comprehensive readiness check reporting Redis/Huey connection, Hugging Face authentication,
|
| 213 |
+
repository accessibility (HF_SOURCE_REPO, HF_WORK_REPO, HF_OUTPUT_REPO), and worker status.
|
| 214 |
+
"""
|
| 215 |
+
redis_ok = False
|
| 216 |
+
redis_error = None
|
| 217 |
+
try:
|
| 218 |
+
redis_client.ping()
|
| 219 |
+
redis_ok = True
|
| 220 |
+
except Exception as e:
|
| 221 |
+
redis_error = str(e)
|
| 222 |
+
|
| 223 |
+
hf_mgr = HFHubManager()
|
| 224 |
+
hf_health = hf_mgr.check_all_health()
|
| 225 |
+
|
| 226 |
+
worker_ok = False
|
| 227 |
+
try:
|
| 228 |
+
huey.pending()
|
| 229 |
+
worker_ok = True
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
|
| 233 |
+
overall_ready = redis_ok and worker_ok and hf_health.get("healthy", False)
|
| 234 |
+
|
| 235 |
+
return {
|
| 236 |
+
"status": "ready" if overall_ready else "degraded",
|
| 237 |
+
"redis": {"status": "ok" if redis_ok else "error", "error": redis_error},
|
| 238 |
+
"huey_queue": {"status": "ok" if worker_ok else "error"},
|
| 239 |
+
"huggingface": hf_health,
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@app.get("/api-docs", include_in_schema=False)
|
| 244 |
+
async def api_docs_redirect():
|
| 245 |
+
return RedirectResponse(url="/docs")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@app.post(
|
| 249 |
+
"/pipelines/run",
|
| 250 |
+
response_model=PipelineRunResponse,
|
| 251 |
+
status_code=202,
|
| 252 |
+
summary="Queue a dataset generation pipeline for execution.",
|
| 253 |
+
)
|
| 254 |
+
def queue_pipeline_run(request: PipelineRunRequest):
|
| 255 |
+
try:
|
| 256 |
+
task = run_pipeline_task(
|
| 257 |
+
node_path=request.node_path,
|
| 258 |
+
config_path=request.config_path,
|
| 259 |
+
parameters=request.parameters,
|
| 260 |
+
)
|
| 261 |
+
return PipelineRunResponse(
|
| 262 |
+
pipeline_id=task.id,
|
| 263 |
+
message="Pipeline run queued successfully.",
|
| 264 |
+
)
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.error(f"ERROR during pipeline queueing: {e}", exc_info=True)
|
| 267 |
+
raise HTTPException(
|
| 268 |
+
status_code=500,
|
| 269 |
+
detail=f"Failed to enqueue pipeline task: {e}",
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@app.get("/jobs", summary="List jobs from HF_WORK_REPO")
|
| 274 |
+
def list_jobs(work_repo: Optional[str] = Query(None)):
|
| 275 |
+
target_repo = work_repo or os.environ.get("HF_WORK_REPO", "")
|
| 276 |
+
if not target_repo:
|
| 277 |
+
return {"work_repo": "", "jobs": []}
|
| 278 |
+
hf_mgr = HFHubManager()
|
| 279 |
+
hf_jobs = hf_mgr.list_jobs(work_repo=target_repo)
|
| 280 |
+
return {"work_repo": target_repo, "jobs": hf_jobs}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@app.get("/jobs/{job_id}/manifest", summary="Inspect job manifest")
|
| 284 |
+
def inspect_job_manifest(job_id: str, work_repo: Optional[str] = Query(None)):
|
| 285 |
+
target_repo = work_repo or os.environ.get("HF_WORK_REPO", "")
|
| 286 |
+
hf_mgr = HFHubManager()
|
| 287 |
+
manifest = hf_mgr.fetch_manifest(job_id=job_id, work_repo=target_repo)
|
| 288 |
+
if not manifest:
|
| 289 |
+
raise HTTPException(status_code=404, detail=f"Manifest for job '{job_id}' not found.")
|
| 290 |
+
return manifest
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@app.get("/jobs/{job_id}/checkpoints", summary="Inspect job checkpoints")
|
| 294 |
+
def inspect_job_checkpoints(job_id: str, work_repo: Optional[str] = Query(None)):
|
| 295 |
+
target_repo = work_repo or os.environ.get("HF_WORK_REPO", "")
|
| 296 |
+
if not target_repo:
|
| 297 |
+
raise HTTPException(status_code=400, detail="HF_WORK_REPO is not configured.")
|
| 298 |
+
hf_mgr = HFHubManager()
|
| 299 |
+
try:
|
| 300 |
+
files = hf_mgr.api.list_repo_files(repo_id=target_repo, repo_type="dataset", token=hf_mgr.token)
|
| 301 |
+
ckpts = [f for f in files if f.startswith(f"jobs/{job_id}/checkpoints/")]
|
| 302 |
+
return {"job_id": job_id, "checkpoints": ckpts}
|
| 303 |
+
except Exception as e:
|
| 304 |
+
raise HTTPException(status_code=500, detail=f"Error checking checkpoints: {e}")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
@app.get("/jobs/{job_id}/outputs", summary="Inspect job output references")
|
| 308 |
+
def inspect_job_outputs(job_id: str, output_repo: Optional[str] = Query(None)):
|
| 309 |
+
target_repo = output_repo or os.environ.get("HF_OUTPUT_REPO", "")
|
| 310 |
+
if not target_repo:
|
| 311 |
+
raise HTTPException(status_code=400, detail="HF_OUTPUT_REPO is not configured.")
|
| 312 |
+
hf_mgr = HFHubManager()
|
| 313 |
+
try:
|
| 314 |
+
files = hf_mgr.api.list_repo_files(repo_id=target_repo, repo_type="dataset", token=hf_mgr.token)
|
| 315 |
+
outputs = [f for f in files if f.startswith(f"datasets/{job_id}/")]
|
| 316 |
+
return {"job_id": job_id, "output_repo": target_repo, "outputs": outputs}
|
| 317 |
+
except Exception as e:
|
| 318 |
+
raise HTTPException(status_code=500, detail=f"Error checking outputs: {e}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@app.post("/jobs/{job_id}/resume", summary="Explicitly resume a job from remote state")
|
| 322 |
+
def resume_job(job_id: str, parameters: Optional[Dict[str, Any]] = Body(None)):
|
| 323 |
+
work_repo = os.environ.get("HF_WORK_REPO", "")
|
| 324 |
+
ckpt_mgr = CheckpointManager()
|
| 325 |
+
manifest = ckpt_mgr.load_remote_manifest(job_id=job_id, work_repo=work_repo)
|
| 326 |
+
if not manifest:
|
| 327 |
+
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found in {work_repo}.")
|
| 328 |
+
|
| 329 |
+
target_output_dir = resolve_job_output_dir(
|
| 330 |
+
node_path=manifest.pipeline,
|
| 331 |
+
config_path=parameters.get("config_path") if parameters else None,
|
| 332 |
+
parameters=parameters,
|
| 333 |
+
durable_job_id=job_id,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
live_prompt_hash = JobManifestManager.compute_live_prompt_hash(
|
| 337 |
+
prompt_folder=parameters.get("prompt_folder") if parameters else None,
|
| 338 |
+
default_prompt_folder=parameters.get("default_prompt_folder") if parameters else None,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
reconciled_manifest, completed_shards, restored_data = ckpt_mgr.reconcile_and_resume_job(
|
| 343 |
+
job_id=job_id,
|
| 344 |
+
target_pipeline=manifest.pipeline,
|
| 345 |
+
target_config_dict=parameters or {},
|
| 346 |
+
target_prompt_hash=live_prompt_hash,
|
| 347 |
+
target_source_repo=manifest.source_repo,
|
| 348 |
+
target_source_revision=manifest.source_revision,
|
| 349 |
+
target_source_path=manifest.source_path,
|
| 350 |
+
target_source_split=manifest.source_split,
|
| 351 |
+
target_output_schema_version=manifest.output_schema_version,
|
| 352 |
+
work_repo=work_repo,
|
| 353 |
+
output_dir=target_output_dir,
|
| 354 |
+
)
|
| 355 |
+
except IncompatibleResumeError as e:
|
| 356 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 357 |
+
except Exception as e:
|
| 358 |
+
raise HTTPException(status_code=500, detail=f"Failed to reconcile job state: {e}")
|
| 359 |
+
|
| 360 |
+
run_params = parameters or {}
|
| 361 |
+
run_params.update({
|
| 362 |
+
"job_id": job_id,
|
| 363 |
+
"output_dir": target_output_dir,
|
| 364 |
+
"hf_source_repo": manifest.source_repo,
|
| 365 |
+
"hf_work_repo": manifest.work_repo,
|
| 366 |
+
"hf_output_repo": manifest.output_repo,
|
| 367 |
+
"source_revision": manifest.source_revision,
|
| 368 |
+
"restored_shards": list(completed_shards),
|
| 369 |
+
})
|
| 370 |
+
|
| 371 |
+
task = run_pipeline_task(
|
| 372 |
+
node_path=manifest.pipeline,
|
| 373 |
+
parameters=run_params,
|
| 374 |
+
)
|
| 375 |
+
return {
|
| 376 |
+
"job_id": job_id,
|
| 377 |
+
"task_id": task.id,
|
| 378 |
+
"output_dir": target_output_dir,
|
| 379 |
+
"message": f"Resume task for durable job '{job_id}' enqueued successfully.",
|
| 380 |
+
"completed_shards": list(completed_shards),
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
@app.post("/jobs/{job_id}/fork", summary="Fork an existing job run into a new durable job")
|
| 385 |
+
def fork_job(
|
| 386 |
+
job_id: str,
|
| 387 |
+
new_job_id: Optional[str] = Query(None),
|
| 388 |
+
parameters: Optional[Dict[str, Any]] = Body(None),
|
| 389 |
+
):
|
| 390 |
+
work_repo = os.environ.get("HF_WORK_REPO", "")
|
| 391 |
+
ckpt_mgr = CheckpointManager()
|
| 392 |
+
parent_manifest = ckpt_mgr.load_remote_manifest(job_id=job_id, work_repo=work_repo)
|
| 393 |
+
if not parent_manifest:
|
| 394 |
+
raise HTTPException(status_code=404, detail=f"Parent job '{job_id}' not found in {work_repo}.")
|
| 395 |
+
|
| 396 |
+
forked_id = new_job_id or f"{job_id}_fork_{int(time.time())}"
|
| 397 |
+
|
| 398 |
+
fork_params = parameters or {}
|
| 399 |
+
run_params = {
|
| 400 |
+
"job_id": forked_id,
|
| 401 |
+
"forked_from_job_id": job_id,
|
| 402 |
+
"hf_source_repo": fork_params.get("hf_source_repo", parent_manifest.source_repo),
|
| 403 |
+
"hf_work_repo": work_repo,
|
| 404 |
+
"hf_output_repo": fork_params.get("hf_output_repo", parent_manifest.output_repo),
|
| 405 |
+
"source_revision": fork_params.get("source_revision", parent_manifest.source_revision),
|
| 406 |
+
"source_path": fork_params.get("source_path", parent_manifest.source_path),
|
| 407 |
+
"source_split": fork_params.get("source_split", parent_manifest.source_split),
|
| 408 |
+
}
|
| 409 |
+
run_params.update(fork_params)
|
| 410 |
+
|
| 411 |
+
task = run_pipeline_task(
|
| 412 |
+
node_path=parent_manifest.pipeline,
|
| 413 |
+
parameters=run_params,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
return {
|
| 417 |
+
"forked_job_id": forked_id,
|
| 418 |
+
"parent_job_id": job_id,
|
| 419 |
+
"task_id": task.id,
|
| 420 |
+
"message": "Forked job queued successfully.",
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@app.get(
|
| 425 |
+
"/pipelines/available",
|
| 426 |
+
response_model=List[str],
|
| 427 |
+
summary="Get available pipeline aliases from super_config.yaml.",
|
| 428 |
+
)
|
| 429 |
+
def get_available_pipelines():
|
| 430 |
+
available_pipelines = []
|
| 431 |
+
if not SUPER_CONFIG_PATH.exists():
|
| 432 |
+
return []
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
with open(SUPER_CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 436 |
+
super_config = yaml.safe_load(f) or {}
|
| 437 |
+
|
| 438 |
+
aliases = super_config.get("path_aliases", {})
|
| 439 |
+
if not isinstance(aliases, dict):
|
| 440 |
+
return []
|
| 441 |
+
|
| 442 |
+
for alias, path_value in aliases.items():
|
| 443 |
+
if isinstance(path_value, str) and not path_value.strip().lower().endswith(".yaml"):
|
| 444 |
+
available_pipelines.append(alias)
|
| 445 |
+
|
| 446 |
+
return sorted(available_pipelines)
|
| 447 |
+
|
| 448 |
+
except Exception as e:
|
| 449 |
+
raise HTTPException(status_code=500, detail=f"Error parsing super config: {e}")
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@app.get(
|
| 453 |
+
"/configs/aliases",
|
| 454 |
+
response_model=List[str],
|
| 455 |
+
summary="Get available config file aliases from super_config.yaml.",
|
| 456 |
+
)
|
| 457 |
+
def get_available_config_aliases():
|
| 458 |
+
config_aliases = []
|
| 459 |
+
if not SUPER_CONFIG_PATH.exists():
|
| 460 |
+
return []
|
| 461 |
+
|
| 462 |
+
try:
|
| 463 |
+
with open(SUPER_CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 464 |
+
super_config = yaml.safe_load(f) or {}
|
| 465 |
+
|
| 466 |
+
aliases = super_config.get("path_aliases", {})
|
| 467 |
+
if not isinstance(aliases, dict):
|
| 468 |
+
return []
|
| 469 |
+
|
| 470 |
+
for alias, path_value in aliases.items():
|
| 471 |
+
if isinstance(path_value, str) and path_value.strip().lower().endswith(".yaml"):
|
| 472 |
+
config_aliases.append(alias)
|
| 473 |
+
|
| 474 |
+
return sorted(config_aliases)
|
| 475 |
+
|
| 476 |
+
except Exception as e:
|
| 477 |
+
raise HTTPException(status_code=500, detail=f"Error parsing super config: {e}")
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
@app.get(
|
| 481 |
+
"/tasks/queue",
|
| 482 |
+
response_model=QueueStatusResponse,
|
| 483 |
+
summary="Get lists of pending and scheduled tasks.",
|
| 484 |
+
)
|
| 485 |
+
def get_queue_status():
|
| 486 |
+
try:
|
| 487 |
+
pending_tasks = [task.id for task in huey.pending()]
|
| 488 |
+
scheduled_tasks = [task.id for task in huey.scheduled()]
|
| 489 |
+
return QueueStatusResponse(
|
| 490 |
+
pending_tasks=pending_tasks,
|
| 491 |
+
scheduled_tasks=scheduled_tasks,
|
| 492 |
+
message=f"Found {len(pending_tasks)} pending and {len(scheduled_tasks)} scheduled tasks.",
|
| 493 |
+
)
|
| 494 |
+
except Exception as e:
|
| 495 |
+
raise HTTPException(status_code=500, detail=f"Failed to retrieve queue status: {e}")
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
@app.get(
|
| 499 |
+
"/tasks/{task_id}/status",
|
| 500 |
+
response_model=PipelineStatusResponse,
|
| 501 |
+
summary="Get the status of a pipeline run.",
|
| 502 |
+
)
|
| 503 |
+
def get_pipeline_status(task_id: str):
|
| 504 |
+
redis_status_key = f"status_for_task:{task_id}"
|
| 505 |
+
try:
|
| 506 |
+
final_status_json = redis_client.get(redis_status_key)
|
| 507 |
+
if final_status_json:
|
| 508 |
+
try:
|
| 509 |
+
status_data = json.loads(final_status_json)
|
| 510 |
+
final_status_enum = PipelineStatus(status_data["status"])
|
| 511 |
+
return PipelineStatusResponse(
|
| 512 |
+
task_id=task_id,
|
| 513 |
+
status=final_status_enum,
|
| 514 |
+
message=status_data.get("message"),
|
| 515 |
+
progress=1.0,
|
| 516 |
+
details=status_data.get("details"),
|
| 517 |
+
)
|
| 518 |
+
except Exception:
|
| 519 |
+
pass
|
| 520 |
+
|
| 521 |
+
pipeline_progress = get_progress(task_id)
|
| 522 |
+
if pipeline_progress:
|
| 523 |
+
return PipelineStatusResponse(
|
| 524 |
+
task_id=task_id,
|
| 525 |
+
status=PipelineStatus.RUNNING,
|
| 526 |
+
message=pipeline_progress.get("message", "Task is running."),
|
| 527 |
+
progress=min(pipeline_progress.get("progress", 0.0), 1.0),
|
| 528 |
+
details={"source": "progress_tracker"},
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
pending_ids = {task.id for task in huey.pending()}
|
| 532 |
+
scheduled_ids = {task.id for task in huey.scheduled()}
|
| 533 |
+
if task_id in pending_ids or task_id in scheduled_ids:
|
| 534 |
+
return PipelineStatusResponse(
|
| 535 |
+
task_id=task_id,
|
| 536 |
+
status=PipelineStatus.PENDING,
|
| 537 |
+
message="Task is pending in the queue.",
|
| 538 |
+
progress=0.0,
|
| 539 |
+
details={"source": "huey_queue"},
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
if huey.is_revoked(task_id):
|
| 543 |
+
return PipelineStatusResponse(
|
| 544 |
+
task_id=task_id,
|
| 545 |
+
status=PipelineStatus.REVOKED,
|
| 546 |
+
message="Task was revoked.",
|
| 547 |
+
progress=0.0,
|
| 548 |
+
details={"source": "huey_revoked"},
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
raise HTTPException(status_code=404, detail=f"Task with ID '{task_id}' not found.")
|
| 552 |
+
except HTTPException:
|
| 553 |
+
raise
|
| 554 |
+
except Exception as e:
|
| 555 |
+
raise HTTPException(status_code=500, detail=f"Error retrieving status: {e}")
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
@app.get(
|
| 559 |
+
"/tasks/{task_id}/parameters",
|
| 560 |
+
response_model=TaskParametersResponse,
|
| 561 |
+
summary="Get the parameters a task was executed with.",
|
| 562 |
+
)
|
| 563 |
+
def get_task_parameters(task_id: str):
|
| 564 |
+
redis_key = f"parameters_for_task:{task_id}"
|
| 565 |
+
try:
|
| 566 |
+
params_json = redis_client.get(redis_key)
|
| 567 |
+
if params_json is None:
|
| 568 |
+
raise HTTPException(status_code=404, detail=f"Parameters for task '{task_id}' not found.")
|
| 569 |
+
parameters = json.loads(params_json)
|
| 570 |
+
return TaskParametersResponse(task_id=task_id, parameters=parameters)
|
| 571 |
+
except HTTPException:
|
| 572 |
+
raise
|
| 573 |
+
except Exception as e:
|
| 574 |
+
raise HTTPException(status_code=500, detail=f"Error retrieving parameters: {e}")
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _find_and_kill_run_augmentoolkit_process() -> Optional[Dict[str, Any]]:
|
| 578 |
+
try:
|
| 579 |
+
result = subprocess.run(["ps", "-ef"], capture_output=True, text=True, check=True)
|
| 580 |
+
for line in result.stdout.splitlines():
|
| 581 |
+
if "run_augmentoolkit.py" in line and "grep" not in line:
|
| 582 |
+
fields = line.split()
|
| 583 |
+
if len(fields) >= 2:
|
| 584 |
+
try:
|
| 585 |
+
pid = int(fields[1])
|
| 586 |
+
os.kill(pid, signal.SIGINT)
|
| 587 |
+
time.sleep(2)
|
| 588 |
+
return {"pid": pid, "killed": True, "method": "SIGINT"}
|
| 589 |
+
except Exception:
|
| 590 |
+
continue
|
| 591 |
+
return None
|
| 592 |
+
except Exception:
|
| 593 |
+
return None
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
@app.post(
|
| 597 |
+
"/tasks/{task_id}/interrupt",
|
| 598 |
+
status_code=200,
|
| 599 |
+
summary="Interrupt a running pipeline task subprocess or revoke a pending task.",
|
| 600 |
+
)
|
| 601 |
+
def interrupt_or_revoke_task(task_id: str):
|
| 602 |
+
redis_pid_key = f"worker_pid_for_task:{task_id}"
|
| 603 |
+
redis_status_key = f"status_for_task:{task_id}"
|
| 604 |
+
|
| 605 |
+
try:
|
| 606 |
+
final_status_json = redis_client.get(redis_status_key)
|
| 607 |
+
if final_status_json:
|
| 608 |
+
try:
|
| 609 |
+
status_data = json.loads(final_status_json)
|
| 610 |
+
existing_status = status_data.get("status", "UNKNOWN").upper()
|
| 611 |
+
if existing_status in ["COMPLETED", "FAILED", "REVOKED"]:
|
| 612 |
+
raise HTTPException(
|
| 613 |
+
status_code=409,
|
| 614 |
+
detail=f"Task {task_id} has already finished with status: {existing_status}.",
|
| 615 |
+
)
|
| 616 |
+
except HTTPException:
|
| 617 |
+
raise
|
| 618 |
+
except Exception:
|
| 619 |
+
pass
|
| 620 |
+
|
| 621 |
+
pid_bytes = redis_client.get(redis_pid_key)
|
| 622 |
+
if pid_bytes:
|
| 623 |
+
try:
|
| 624 |
+
pid = int(pid_bytes)
|
| 625 |
+
os.kill(pid, signal.SIGINT)
|
| 626 |
+
time.sleep(1)
|
| 627 |
+
set_final_status(task_id, "REVOKED", "Task interrupted via SIGINT.")
|
| 628 |
+
return {"message": f"Task {task_id} interrupted via SIGINT."}
|
| 629 |
+
except Exception:
|
| 630 |
+
pass
|
| 631 |
+
|
| 632 |
+
was_revoked = huey.revoke(task_id, revoke_once=True)
|
| 633 |
+
if was_revoked:
|
| 634 |
+
set_final_status(task_id, "REVOKED", "Task revoked while pending.")
|
| 635 |
+
return {"message": f"Task {task_id} was pending and has been revoked."}
|
| 636 |
+
|
| 637 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not running or not found.")
|
| 638 |
+
except HTTPException:
|
| 639 |
+
raise
|
| 640 |
+
except Exception as e:
|
| 641 |
+
raise HTTPException(status_code=500, detail=f"Error interrupting task: {e}")
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
@app.get("/tasks/{task_id}/logs", summary="Get logs for a specific task.")
|
| 645 |
+
def get_task_logs(task_id: str, tail: Optional[int] = Query(None, ge=1)):
|
| 646 |
+
log_file_path = LOGS_DIR / f"{task_id}.log"
|
| 647 |
+
if not log_file_path.exists():
|
| 648 |
+
raise HTTPException(status_code=404, detail=f"Log file for task {task_id} not found.")
|
| 649 |
+
|
| 650 |
+
try:
|
| 651 |
+
with open(log_file_path, "r", encoding="utf-8") as f:
|
| 652 |
+
lines = f.readlines()
|
| 653 |
+
log_content = "".join(lines[-tail:]) if tail else "".join(lines)
|
| 654 |
+
return JSONResponse(content={"task_id": task_id, "logs": log_content})
|
| 655 |
+
except Exception as e:
|
| 656 |
+
raise HTTPException(status_code=500, detail=f"Failed to read log file: {e}")
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
@app.delete("/logs", status_code=200, summary="Clear all task log files.")
|
| 660 |
+
def clear_all_logs():
|
| 661 |
+
deleted_count = 0
|
| 662 |
+
try:
|
| 663 |
+
for item in LOGS_DIR.iterdir():
|
| 664 |
+
if item.is_file() and item.suffix == ".log":
|
| 665 |
+
item.unlink()
|
| 666 |
+
deleted_count += 1
|
| 667 |
+
return {"message": f"Successfully deleted {deleted_count} log file(s)."}
|
| 668 |
+
except Exception as e:
|
| 669 |
+
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
@app.get(
|
| 673 |
+
"/tasks/{task_id}/outputs/download", summary="Download output directory for a task."
|
| 674 |
+
)
|
| 675 |
+
def download_task_output(task_id: str):
|
| 676 |
+
redis_key = f"output_dir_for_task:{task_id}"
|
| 677 |
+
output_dir_str = redis_client.get(redis_key)
|
| 678 |
+
if not output_dir_str:
|
| 679 |
+
raise HTTPException(status_code=404, detail=f"Output directory not found for task {task_id}.")
|
| 680 |
+
|
| 681 |
+
task_output_dir = PyPath(output_dir_str).resolve()
|
| 682 |
+
if not task_output_dir.is_dir():
|
| 683 |
+
raise HTTPException(status_code=404, detail="Output directory does not exist.")
|
| 684 |
+
|
| 685 |
+
zip_filename = f"task_{task_id}_output.zip"
|
| 686 |
+
temp_zip_path = PyPath(tempfile.gettempdir()) / zip_filename
|
| 687 |
+
zip_directory(task_output_dir, temp_zip_path)
|
| 688 |
+
return FileResponse(temp_zip_path, media_type="application/zip", filename=zip_filename)
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
@app.get(
|
| 692 |
+
"/outputs/structure/{relative_path:path}",
|
| 693 |
+
response_model=List[FileStructure],
|
| 694 |
+
summary="Get structure of a path within the outputs directory.",
|
| 695 |
+
)
|
| 696 |
+
def get_output_structure(relative_path: str = "."):
|
| 697 |
+
return handle_get_structure(OUTPUTS_DIR, relative_path)
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
@app.get(
|
| 701 |
+
"/inputs/structure/{relative_path:path}",
|
| 702 |
+
response_model=List[FileStructure],
|
| 703 |
+
summary="Get structure of a path within the inputs directory.",
|
| 704 |
+
)
|
| 705 |
+
def get_input_structure(relative_path: str = "."):
|
| 706 |
+
return handle_get_structure(INPUTS_DIR, relative_path)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
@app.get(
|
| 710 |
+
"/configs/structure/{relative_path:path}",
|
| 711 |
+
response_model=List[FileStructure],
|
| 712 |
+
summary="Get structure of a path within the configs directory.",
|
| 713 |
+
)
|
| 714 |
+
def get_config_structure(relative_path: str = "."):
|
| 715 |
+
return handle_get_structure(CONFIGS_DIR, relative_path)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
FRONTEND_DIST_DIR = PyPath("atk-interface/dist")
|
| 719 |
+
|
| 720 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 721 |
+
async def serve_frontend(full_path: str):
|
| 722 |
+
file_path = FRONTEND_DIST_DIR / full_path
|
| 723 |
+
if file_path.is_file():
|
| 724 |
+
return FileResponse(file_path)
|
| 725 |
+
|
| 726 |
+
assets_path = FRONTEND_DIST_DIR / "assets" / full_path
|
| 727 |
+
if assets_path.is_file():
|
| 728 |
+
return FileResponse(assets_path)
|
| 729 |
+
|
| 730 |
+
index_html = FRONTEND_DIST_DIR / "index.html"
|
| 731 |
+
if index_html.is_file():
|
| 732 |
+
return FileResponse(index_html)
|
| 733 |
+
|
| 734 |
+
if not full_path or full_path == "/":
|
| 735 |
+
return {"status": "ok", "message": "Augmentoolkit API is running (frontend not built)."}
|
| 736 |
+
|
| 737 |
+
raise HTTPException(status_code=404, detail="Not found")
|
api_README.md
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Augmentoolkit API Documentation
|
| 2 |
+
|
| 3 |
+
This document describes the REST API endpoints for interacting with the Augmentoolkit server, allowing you to manage and run data generation pipelines, monitor their status, and manage associated files (inputs, outputs, logs, configs).
|
| 4 |
+
|
| 5 |
+
The API is built using FastAPI and utilizes Huey for background task queueing and Redis for state management (task status, PID mapping, output directory mapping).
|
| 6 |
+
|
| 7 |
+
**Base URL:** (Assuming the server runs locally on port 8000) `http://127.0.0.1:8000`
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Core Concepts
|
| 12 |
+
|
| 13 |
+
* **Pipelines:** Defined sequences of data processing steps, typically corresponding to Python functions within the codebase (e.g., in a `pipelines` directory).
|
| 14 |
+
* **Tasks:** An instance of a pipeline run, managed by the Huey background task queue. Each task has a unique `task_id`.
|
| 15 |
+
* **Nodes:** Refers to the importable path of the Python function that executes a pipeline (e.g., `pipelines.my_pipeline.run`).
|
| 16 |
+
* **Configs:** YAML configuration files (stored in `./external_configs/`) that provide parameters for pipeline runs. Can be overridden by parameters in the API request.
|
| 17 |
+
* **Super Config (`super_config.yaml`):** Defines path aliases used for resolving node and config paths.
|
| 18 |
+
* **Directories:**
|
| 19 |
+
* `./inputs/`: Stores input data files used by pipelines.
|
| 20 |
+
* `./outputs/`: Stores output files generated by pipelines. Output directories are often named using the `task_id`.
|
| 21 |
+
* `./external_configs/`: Stores reusable pipeline configuration files.
|
| 22 |
+
* `./logs/`: Stores log files (`<task_id>.log`) for each pipeline task run.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Endpoints
|
| 27 |
+
|
| 28 |
+
### Health Check
|
| 29 |
+
|
| 30 |
+
* **GET /**
|
| 31 |
+
* **Summary:** Health Check
|
| 32 |
+
* **Description:** Basic health check endpoint. Returns a simple message indicating the API is running.
|
| 33 |
+
* **Response:** `{"message": "Augmentoolkit API is running."}`
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
### Pipeline Execution
|
| 38 |
+
|
| 39 |
+
* **POST /pipelines/run**
|
| 40 |
+
* **Summary:** Queue a dataset generation pipeline for execution.
|
| 41 |
+
* **Status Code:** 202 Accepted
|
| 42 |
+
* **Description:** Resolves node and config paths using aliases from `super_config.yaml` and enqueues a pipeline task using Huey (`tasks.run_pipeline_task`). The task executes the pipeline defined by `node_path` potentially using configuration from `config_path` and `parameters` via `run_augmentoolkit.py`.
|
| 43 |
+
* **Request Body:**
|
| 44 |
+
```json
|
| 45 |
+
{
|
| 46 |
+
"node_path": "string (required, can use aliases)",
|
| 47 |
+
"config_path": "string (optional, relative to ./external_configs/ or absolute, can use aliases)",
|
| 48 |
+
"parameters": { /* JSON object of key-value pairs (optional) */ }
|
| 49 |
+
}
|
| 50 |
+
```
|
| 51 |
+
* **Response:**
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"pipeline_id": "string (Huey Task ID)",
|
| 55 |
+
"message": "Pipeline run queued successfully."
|
| 56 |
+
}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
* **GET /pipelines/available**
|
| 60 |
+
* **Summary:** Get available pipeline aliases from super_config.yaml.
|
| 61 |
+
* **Description:** Reads the `super_config.yaml` file and returns a sorted list of path aliases (keys) whose corresponding values do not end with `.yaml`. These aliases typically represent the runnable entry points for pipelines that can be passed to the `node_path` field in the `/pipelines/run` endpoint.
|
| 62 |
+
* **Path Parameters:** None
|
| 63 |
+
* **Query Parameters:** None
|
| 64 |
+
* **Response:** A JSON list of strings.
|
| 65 |
+
```json
|
| 66 |
+
[
|
| 67 |
+
"alias1",
|
| 68 |
+
"alias2",
|
| 69 |
+
"another-pipeline-alias",
|
| 70 |
+
...
|
| 71 |
+
]
|
| 72 |
+
```
|
| 73 |
+
* **Error Responses:**
|
| 74 |
+
* 500 Internal Server Error: If `super_config.yaml` cannot be found or parsed, or an unexpected error occurs. (Note: If the file is found but empty or `path_aliases` is missing/invalid, it currently returns an empty list `[]` gracefully).
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
### Task Status & Management
|
| 79 |
+
|
| 80 |
+
* **GET /tasks/{task_id}/status**
|
| 81 |
+
* **Summary:** Get the status of a pipeline run.
|
| 82 |
+
* **Description:** Retrieves the current status (PENDING, RUNNING, COMPLETED, FAILED, REVOKED) and result (if finished) of a specific pipeline task using its `task_id`. Checks Huey's state and potentially Redis for progress information.
|
| 83 |
+
* **Path Parameters:**
|
| 84 |
+
* `task_id`: The Huey task ID obtained from `/pipelines/run`.
|
| 85 |
+
* **Response:**
|
| 86 |
+
```json
|
| 87 |
+
{
|
| 88 |
+
"task_id": "string",
|
| 89 |
+
"status": "string (PENDING | RUNNING | COMPLETED | FAILED | REVOKED)",
|
| 90 |
+
"message": "string (optional)",
|
| 91 |
+
"progress": "float (optional, 0.0-1.0)",
|
| 92 |
+
"details": { /* Optional details, e.g., error info or result */ }
|
| 93 |
+
}
|
| 94 |
+
```
|
| 95 |
+
* **Error Responses:**
|
| 96 |
+
* 404 Not Found: If `task_id` does not exist.
|
| 97 |
+
* 500 Internal Server Error: If status retrieval fails.
|
| 98 |
+
|
| 99 |
+
* **GET /tasks/{task_id}/parameters**
|
| 100 |
+
* **Summary:** Get the parameters a task was executed with.
|
| 101 |
+
* **Description:** Retrieves the parameters (combined from config file and request overrides) that were used to start a specific task run. Parameters are stored in Redis when the task begins execution and persist based on a configured timeout (typically several days).
|
| 102 |
+
* **Path Parameters:**
|
| 103 |
+
* `task_id`: The Huey task ID obtained from `/pipelines/run`.
|
| 104 |
+
* **Response:**
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"task_id": "string",
|
| 108 |
+
"parameters": { /* JSON object of key-value pairs used for the task */ }
|
| 109 |
+
}
|
| 110 |
+
```
|
| 111 |
+
* **Error Responses:**
|
| 112 |
+
* 404 Not Found: If the `task_id` does not exist or if the parameters were not stored/have expired.
|
| 113 |
+
* 500 Internal Server Error: If retrieving or parsing the parameters from Redis fails (e.g., data corruption).
|
| 114 |
+
|
| 115 |
+
* **POST /tasks/{task_id}/interrupt**
|
| 116 |
+
* **Summary:** Interrupt a running pipeline task subprocess or revoke a pending task.
|
| 117 |
+
* **Description:** Attempts to interrupt a running task or revoke a pending one.
|
| 118 |
+
* If the task is **RUNNING**: Looks up the subprocess PID associated with the `task_id` in Redis (set by `tasks.py`) and sends a `SIGINT` signal to it using `os.kill`.
|
| 119 |
+
* If the task is **PENDING**: Revokes the task using `huey.revoke`.
|
| 120 |
+
* Reports an error if the task is already finished or revoked.
|
| 121 |
+
* **Path Parameters:**
|
| 122 |
+
* `task_id`: The Huey task ID.
|
| 123 |
+
* **Response:**
|
| 124 |
+
* `{"message": "Sent SIGINT signal..."}` (for running tasks)
|
| 125 |
+
* `{"message": "Task ... was pending and has been successfully revoked."}` (for pending tasks)
|
| 126 |
+
* **Error Responses:**
|
| 127 |
+
* 404 Not Found: If `task_id` does not exist or the subprocess PID is gone.
|
| 128 |
+
* 409 Conflict: If the task is already finished/revoked or fails to revoke.
|
| 129 |
+
* 500 Internal Server Error: If signaling fails or other errors occur.
|
| 130 |
+
|
| 131 |
+
* **GET /tasks/queue**
|
| 132 |
+
* **Summary:** Get lists of pending and scheduled tasks.
|
| 133 |
+
* **Description:** Retrieves the IDs of tasks currently pending or scheduled in the Huey queue using `huey.pending()` and `huey.scheduled()`. Note: This does not reliably show tasks that are *actively running*.
|
| 134 |
+
* **Path Parameters:** None
|
| 135 |
+
* **Query Parameters:** None
|
| 136 |
+
* **Response:**
|
| 137 |
+
```json
|
| 138 |
+
{
|
| 139 |
+
"pending_tasks": ["string (task_id)", ...],
|
| 140 |
+
"scheduled_tasks": ["string (task_id)", ...],
|
| 141 |
+
"message": "string (e.g., Found X pending and Y scheduled tasks.)"
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
+
* **Error Responses:**
|
| 145 |
+
* 500 Internal Server Error: If retrieving queue status fails.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
### Log Management
|
| 150 |
+
|
| 151 |
+
* **GET /tasks/{task_id}/logs**
|
| 152 |
+
* **Summary:** Get logs for a specific task.
|
| 153 |
+
* **Description:** Retrieves the content of the log file (`./logs/<task_id>.log`) generated by a specific task run.
|
| 154 |
+
* **Path Parameters:**
|
| 155 |
+
* `task_id`: The Huey task ID.
|
| 156 |
+
* **Query Parameters:**
|
| 157 |
+
* `tail` (int, optional): Return only the last N lines of the log.
|
| 158 |
+
* **Response:**
|
| 159 |
+
```json
|
| 160 |
+
{
|
| 161 |
+
"task_id": "string",
|
| 162 |
+
"message": "string",
|
| 163 |
+
"logs": "string (log content)"
|
| 164 |
+
}
|
| 165 |
+
```
|
| 166 |
+
* **Error Responses:**
|
| 167 |
+
* 404 Not Found: If the log file doesn't exist.
|
| 168 |
+
* 500 Internal Server Error: If reading the file fails.
|
| 169 |
+
|
| 170 |
+
* **DELETE /tasks/{task_id}/logs**
|
| 171 |
+
* **Summary:** Delete log file for a specific task.
|
| 172 |
+
* **Description:** Deletes the specific log file (`./logs/<task_id>.log`).
|
| 173 |
+
* **Path Parameters:**
|
| 174 |
+
* `task_id`: The Huey task ID.
|
| 175 |
+
* **Response:** `{"message": "Successfully deleted log file..."}`
|
| 176 |
+
* **Error Responses:**
|
| 177 |
+
* 404 Not Found: If the log file doesn't exist.
|
| 178 |
+
* 500 Internal Server Error: If deletion fails.
|
| 179 |
+
|
| 180 |
+
* **GET /logs**
|
| 181 |
+
* **Summary:** List all available log files.
|
| 182 |
+
* **Description:** Returns a list of all `.log` filenames currently in the `./logs/` directory.
|
| 183 |
+
* **Response:** `{"log_files": ["task_id1.log", "task_id2.log", ...]}`
|
| 184 |
+
* **Error Responses:**
|
| 185 |
+
* 500 Internal Server Error: If listing the directory fails.
|
| 186 |
+
|
| 187 |
+
* **DELETE /logs**
|
| 188 |
+
* **Summary:** Clear all task log files.
|
| 189 |
+
* **Description:** Deletes all files ending with `.log` within the `./logs/` directory.
|
| 190 |
+
* **Response:** Reports the number of deleted files and any errors encountered.
|
| 191 |
+
```json
|
| 192 |
+
{
|
| 193 |
+
"message": "Successfully deleted X log file(s)...",
|
| 194 |
+
"errors": [ /* list of error strings, if any */ ]
|
| 195 |
+
}
|
| 196 |
+
```
|
| 197 |
+
* **Error Responses:**
|
| 198 |
+
* 500 Internal Server Error: If accessing or clearing the directory fails.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
### Output File Management
|
| 203 |
+
|
| 204 |
+
* **GET /tasks/{task_id}/outputs/download**
|
| 205 |
+
* **Summary:** Download output directory for a task.
|
| 206 |
+
* **Description:** Retrieves the path of the output directory associated with the `task_id` from Redis (set by `tasks.py`), zips the entire directory content, and returns it as a downloadable file (`task_<task_id>_output.zip`).
|
| 207 |
+
* **Path Parameters:**
|
| 208 |
+
* `task_id`: The Huey task ID.
|
| 209 |
+
* **Response:** A zip file (`application/zip`).
|
| 210 |
+
* **Error Responses:**
|
| 211 |
+
* 404 Not Found: If the task ID mapping or the directory itself doesn't exist.
|
| 212 |
+
* 500 Internal Server Error: If zipping or file access fails.
|
| 213 |
+
|
| 214 |
+
* **GET /tasks/{task_id}/outputs/structure**
|
| 215 |
+
* **Summary:** Get structure of a task's output directory.
|
| 216 |
+
* **Description:** Retrieves the path of the output directory associated with the `task_id` from Redis and returns its file/folder structure as a JSON list. Paths are relative to the specific task's output directory root.
|
| 217 |
+
* **Path Parameters:**
|
| 218 |
+
* `task_id`: The Huey task ID.
|
| 219 |
+
* **Response:** List of `FileStructure` objects (see below).
|
| 220 |
+
* **Error Responses:**
|
| 221 |
+
* 404 Not Found: If the task ID mapping or the directory itself doesn't exist.
|
| 222 |
+
* 500 Internal Server Error: If reading the directory fails.
|
| 223 |
+
|
| 224 |
+
* **GET /outputs/structure/{relative_path:path}**
|
| 225 |
+
* **Summary:** Get structure of a path within the main outputs directory.
|
| 226 |
+
* **Description:** Retrieves the file/folder structure for a given path relative to the root `./outputs/` directory. Uses the shared `handle_get_structure` helper.
|
| 227 |
+
* **Path Parameters:**
|
| 228 |
+
* `relative_path`: The path relative to `./outputs/` (e.g., `my_folder/sub_folder` or leave empty/`.` for the root).
|
| 229 |
+
* **Response:** List of `FileStructure` objects:
|
| 230 |
+
```json
|
| 231 |
+
[
|
| 232 |
+
{
|
| 233 |
+
"path": "string (relative path)",
|
| 234 |
+
"is_dir": true,
|
| 235 |
+
"children": [ /* nested FileStructure objects */ ]
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"path": "string (relative path)",
|
| 239 |
+
"is_dir": false,
|
| 240 |
+
"children": null
|
| 241 |
+
}
|
| 242 |
+
]
|
| 243 |
+
```
|
| 244 |
+
* **Error Responses:**
|
| 245 |
+
* 404 Not Found: If the path doesn't exist.
|
| 246 |
+
* 400 Bad Request: If the path is not a directory.
|
| 247 |
+
* 500 Internal Server Error: If listing fails.
|
| 248 |
+
|
| 249 |
+
* **GET /outputs/download/{relative_path:path}**
|
| 250 |
+
* **Summary:** Download a specific file or folder from outputs.
|
| 251 |
+
* **Description:** Downloads a specific file or zips and downloads a specific folder relative to the root `./outputs/` directory. Uses the shared `handle_download_item` helper.
|
| 252 |
+
* **Path Parameters:**
|
| 253 |
+
* `relative_path`: The path relative to `./outputs/`.
|
| 254 |
+
* **Response:** A file (`application/octet-stream` or detected type) or a zip archive (`application/zip`).
|
| 255 |
+
* **Error Responses:**
|
| 256 |
+
* 404 Not Found: If the path doesn't exist.
|
| 257 |
+
* 500 Internal Server Error: If reading/zipping fails.
|
| 258 |
+
|
| 259 |
+
* **DELETE /outputs/{relative_path:path}**
|
| 260 |
+
* **Summary:** Delete a file or folder from outputs.
|
| 261 |
+
* **Description:** Deletes a specific file or folder relative to the root `./outputs/` directory. Uses the shared `handle_delete_item` helper. **Warning:** This is permanent.
|
| 262 |
+
* **Path Parameters:**
|
| 263 |
+
* `relative_path`: The path relative to `./outputs/`.
|
| 264 |
+
* **Response:** `{"message": "Successfully deleted..."}`
|
| 265 |
+
* **Error Responses:**
|
| 266 |
+
* 404 Not Found: If the path doesn't exist.
|
| 267 |
+
* 400 Bad Request: If attempting to delete the root `./outputs` directory.
|
| 268 |
+
* 500 Internal Server Error: If deletion fails.
|
| 269 |
+
|
| 270 |
+
* **POST /outputs/move**
|
| 271 |
+
* **Summary:** Move/Rename a file or folder within outputs.
|
| 272 |
+
* **Description:** Moves or renames a file or folder within the `./outputs/` directory using `shutil.move`.
|
| 273 |
+
* The source path must exist.
|
| 274 |
+
* The destination path must *not* already exist (to prevent overwrites).
|
| 275 |
+
* The parent directory of the destination path must exist.
|
| 276 |
+
* Cannot be used to move the root `./outputs` directory itself.
|
| 277 |
+
* **Request Body:**
|
| 278 |
+
```json
|
| 279 |
+
{
|
| 280 |
+
"source_relative_path": "string (required, current relative path)",
|
| 281 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 282 |
+
}
|
| 283 |
+
```
|
| 284 |
+
* **Response (Status 200):**
|
| 285 |
+
```json
|
| 286 |
+
{
|
| 287 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 288 |
+
}
|
| 289 |
+
```
|
| 290 |
+
* **Error Responses:**
|
| 291 |
+
* 400 Bad Request: If source/destination paths are invalid, if the destination parent doesn't exist, or if attempting to move the root directory.
|
| 292 |
+
* 404 Not Found: If the source path does not exist.
|
| 293 |
+
* 409 Conflict: If the destination path already exists.
|
| 294 |
+
* 500 Internal Server Error: If the move operation fails for other reasons (e.g., permissions).
|
| 295 |
+
|
| 296 |
+
---
|
| 297 |
+
|
| 298 |
+
### Input File Management
|
| 299 |
+
|
| 300 |
+
*Input file management endpoints mirror the Output File Management endpoints, but operate on the `./inputs/` directory.* Also, it has a route to create a directory, too.
|
| 301 |
+
|
| 302 |
+
* **GET /inputs/structure/{relative_path:path}**
|
| 303 |
+
* **Summary:** Get structure of a path within the inputs directory.
|
| 304 |
+
* **Description:** Retrieves structure relative to `./inputs/`.
|
| 305 |
+
* **Response:** List of `FileStructure` objects.
|
| 306 |
+
|
| 307 |
+
* **GET /inputs/download/{relative_path:path}**
|
| 308 |
+
* **Summary:** Download a specific file or folder from inputs.
|
| 309 |
+
* **Description:** Downloads file/folder relative to `./inputs/`.
|
| 310 |
+
* **Response:** File or Zip archive.
|
| 311 |
+
|
| 312 |
+
* **DELETE /inputs/{relative_path:path}**
|
| 313 |
+
* **Summary:** Delete a file or folder from inputs.
|
| 314 |
+
* **Description:** Deletes file/folder relative to `./inputs/`. **Warning:** Permanent.
|
| 315 |
+
* **Response:** `{"message": "Successfully deleted..."}`
|
| 316 |
+
|
| 317 |
+
* **POST /inputs/move**
|
| 318 |
+
* **Summary:** Move/Rename a file or folder within inputs.
|
| 319 |
+
* **Description:** Moves or renames a file or folder within the `./inputs/` directory using `shutil.move`. See `/outputs/move` for detailed behavior and error conditions.
|
| 320 |
+
* **Request Body:**
|
| 321 |
+
```json
|
| 322 |
+
{
|
| 323 |
+
"source_relative_path": "string (required, current relative path)",
|
| 324 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 325 |
+
}
|
| 326 |
+
```
|
| 327 |
+
* **Response (Status 200):**
|
| 328 |
+
```json
|
| 329 |
+
{
|
| 330 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 331 |
+
}
|
| 332 |
+
```
|
| 333 |
+
* **Error Responses:** 400, 404, 409, 500 (see `/outputs/move`)
|
| 334 |
+
|
| 335 |
+
* **POST /inputs/directory**
|
| 336 |
+
* **Summary:** Create a new directory within the inputs folder.
|
| 337 |
+
* **Description:** Creates a new directory at the specified `relative_path` within the `./inputs/` directory. The path can include parent directories (e.g., `existing_folder/new_subfolder`).
|
| 338 |
+
* **Request Body:**
|
| 339 |
+
```json
|
| 340 |
+
{
|
| 341 |
+
"relative_path": "string (required, relative path)"
|
| 342 |
+
}
|
| 343 |
+
```
|
| 344 |
+
* **Response (Status 201):**
|
| 345 |
+
```json
|
| 346 |
+
{
|
| 347 |
+
"message": "Successfully created directory '{relative_path}' in inputs."
|
| 348 |
+
}
|
| 349 |
+
```
|
| 350 |
+
* **Error Responses:**
|
| 351 |
+
* 400 Bad Request: If `relative_path` is invalid (empty, '.', targets root) or if the parent directory specified in the path does not exist.
|
| 352 |
+
* 409 Conflict: If a file or directory already exists at the target `relative_path`.
|
| 353 |
+
* 500 Internal Server Error: If directory creation fails for other reasons (e.g., permissions).
|
| 354 |
+
|
| 355 |
+
* **POST /inputs/upload/{relative_path:path}**
|
| 356 |
+
* **Summary:** Upload files to a specific path within inputs, automatically extracting zip files.
|
| 357 |
+
* **Description:** Uploads one or more files to a specified path within `./inputs/`. The path is created if it doesn't exist.
|
| 358 |
+
* If a file ends with `.zip` (case-insensitive), it is automatically extracted into the target `relative_path`.
|
| 359 |
+
* Upon successful extraction, the original `.zip` file is deleted.
|
| 360 |
+
* If extraction fails, the original `.zip` file is kept, and an error is reported.
|
| 361 |
+
* Non-zip files are saved directly to the `relative_path`.
|
| 362 |
+
* Allows uploading directly to the root `./inputs/` directory (e.g., by specifying `.` as the `relative_path`).
|
| 363 |
+
* **Path Parameters:**
|
| 364 |
+
* `relative_path`: Target path relative to `./inputs/`. Files (including extracted zip contents) will be placed here.
|
| 365 |
+
* **Request Body:** `multipart/form-data` containing one or more files.
|
| 366 |
+
* **Response (Status 201 or 207):**
|
| 367 |
+
```json
|
| 368 |
+
{
|
| 369 |
+
"message": "Upload process completed...",
|
| 370 |
+
"uploaded_files": [
|
| 371 |
+
{"filename": "string", "size": int, "path": "string (relative path)", "error": "string (optional, only on failed extraction)"}
|
| 372 |
+
],
|
| 373 |
+
"extracted_zips": [
|
| 374 |
+
{"filename": "string (original zip)", "extracted_to": "string (relative path)"}
|
| 375 |
+
],
|
| 376 |
+
"errors": [ /* list of general error strings, if any */ ]
|
| 377 |
+
}
|
| 378 |
+
```
|
| 379 |
+
* **Error Responses:**
|
| 380 |
+
* 500 Internal Server Error: If directory creation, file writing, or extraction fails unexpectedly.
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
### Config File Management
|
| 385 |
+
|
| 386 |
+
*Configuration file management endpoints operate on the `./external_configs/` directory.*
|
| 387 |
+
|
| 388 |
+
* **GET /configs/aliases**
|
| 389 |
+
* **Summary:** Get available config file aliases from super_config.yaml.
|
| 390 |
+
* **Description:** Reads the `super_config.yaml` file and returns a sorted list of path aliases (keys) whose corresponding values *do* end with `.yaml` (case-insensitive). These aliases can be used to identify existing configuration files for duplication or other purposes.
|
| 391 |
+
* **Path Parameters:** None
|
| 392 |
+
* **Query Parameters:** None
|
| 393 |
+
* **Response:** A JSON list of strings.
|
| 394 |
+
```json
|
| 395 |
+
[
|
| 396 |
+
"config-alias1",
|
| 397 |
+
"another_config.yaml",
|
| 398 |
+
"path/to/config_alias_3",
|
| 399 |
+
...
|
| 400 |
+
]
|
| 401 |
+
```
|
| 402 |
+
* **Error Responses:**
|
| 403 |
+
* 500 Internal Server Error: If `super_config.yaml` cannot be found or parsed, or an unexpected error occurs. (Note: If the file is found but empty or `path_aliases` is missing/invalid, it returns an empty list `[]` gracefully).
|
| 404 |
+
|
| 405 |
+
* **GET /configs/structure/{relative_path:path}**
|
| 406 |
+
* **Summary:** Get structure of a path within the configs directory.
|
| 407 |
+
* **Description:** Retrieves structure relative to `./external_configs/`.
|
| 408 |
+
* **Response:** List of `FileStructure` objects.
|
| 409 |
+
|
| 410 |
+
* **GET /configs/content/{relative_path:path}**
|
| 411 |
+
* **Summary:** Get content of a specific config file.
|
| 412 |
+
* **Description:** Retrieves the plain text content of a specific file relative to `./external_configs/`.
|
| 413 |
+
* **Path Parameters:**
|
| 414 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 415 |
+
* **Response:** Plain text file content (`text/plain`).
|
| 416 |
+
* **Error Responses:**
|
| 417 |
+
* 404 Not Found: If the file doesn't exist.
|
| 418 |
+
* 400 Bad Request: If the path is not a file.
|
| 419 |
+
|
| 420 |
+
* **POST /configs/content/{relative_path:path}**
|
| 421 |
+
* **Summary:** Create or update a config file.
|
| 422 |
+
* **Description:** Creates a new file or overwrites an existing file at the specified path relative to `./external_configs/`. Parent directories are created if they don't exist.
|
| 423 |
+
* **Path Parameters:**
|
| 424 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 425 |
+
* **Request Body:** Raw text content (`text/plain`).
|
| 426 |
+
* **Response:** `{"message": "Successfully saved config..."}`
|
| 427 |
+
* **Error Responses:**
|
| 428 |
+
* 400 Bad Request: If attempting to write to the root.
|
| 429 |
+
* 500 Internal Server Error: If writing fails.
|
| 430 |
+
|
| 431 |
+
* **DELETE /configs/{relative_path:path}**
|
| 432 |
+
* **Summary:** Delete a specific config file or directory.
|
| 433 |
+
* **Description:** Deletes a specific file or directory (recursively) relative to `./external_configs/`. **Warning:** Permanent.
|
| 434 |
+
* **Path Parameters:**
|
| 435 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 436 |
+
* **Response:** `{"message": "Successfully deleted config {file|directory}..."}`
|
| 437 |
+
* **Error Responses:**
|
| 438 |
+
* 404 Not Found: If the file or directory doesn't exist.
|
| 439 |
+
* 400 Bad Request: If attempting to delete the root `./external_configs` directory.
|
| 440 |
+
* 500 Internal Server Error: If deletion fails.
|
| 441 |
+
|
| 442 |
+
* **POST /configs/move**
|
| 443 |
+
* **Summary:** Move/Rename a file or folder within configs.
|
| 444 |
+
* **Description:** Moves or renames a file or folder within the `./external_configs/` directory using `shutil.move`. See `/outputs/move` for detailed behavior and error conditions.
|
| 445 |
+
* **Request Body:**
|
| 446 |
+
```json
|
| 447 |
+
{
|
| 448 |
+
"source_relative_path": "string (required, current relative path)",
|
| 449 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 450 |
+
}
|
| 451 |
+
```
|
| 452 |
+
* **Response (Status 200):**
|
| 453 |
+
```json
|
| 454 |
+
{
|
| 455 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 456 |
+
}
|
| 457 |
+
```
|
| 458 |
+
* **Error Responses:** 400, 404, 409, 500 (see `/outputs/move`)
|
| 459 |
+
|
| 460 |
+
* **POST /configs/directory**
|
| 461 |
+
* **Summary:** Create a new directory within the configs folder.
|
| 462 |
+
* **Description:** Creates a new directory at the specified `relative_path` within the `./external_configs/` directory. Uses the shared `handle_create_directory` helper.
|
| 463 |
+
* **Request Body:**
|
| 464 |
+
```json
|
| 465 |
+
{
|
| 466 |
+
"relative_path": "string (required, relative path)"
|
| 467 |
+
}
|
| 468 |
+
```
|
| 469 |
+
* **Response (Status 201):**
|
| 470 |
+
```json
|
| 471 |
+
{
|
| 472 |
+
"message": "Successfully created directory '{relative_path}' in external_configs."
|
| 473 |
+
}
|
| 474 |
+
```
|
| 475 |
+
* **Error Responses:**
|
| 476 |
+
* 400 Bad Request: If `relative_path` is invalid (empty, '.', targets root) or if the parent directory specified in the path does not exist.
|
| 477 |
+
* 409 Conflict: If a file or directory already exists at the target `relative_path`.
|
| 478 |
+
* 500 Internal Server Error: If directory creation fails for other reasons (e.g., permissions).
|
| 479 |
+
|
| 480 |
+
* **POST /configs/duplicate**
|
| 481 |
+
* **Summary:** Duplicate a pipeline config into external_configs.
|
| 482 |
+
* **Description:** Duplicates a configuration file identified by a `source_alias` (from `super_config.yaml`) into the `./external_configs/` directory at the specified `destination_relative_path`. The source alias must resolve to an existing `.yaml` file. The destination path must not already exist, and its parent directory must exist.
|
| 483 |
+
* **Request Body:**
|
| 484 |
+
```json
|
| 485 |
+
{
|
| 486 |
+
"source_alias": "string (required, alias from super_config.yaml)",
|
| 487 |
+
"destination_relative_path": "string (required, new relative path in external_configs)"
|
| 488 |
+
}
|
| 489 |
+
```
|
| 490 |
+
* **Response (Status 201):**
|
| 491 |
+
```json
|
| 492 |
+
{
|
| 493 |
+
"message": "Successfully duplicated config from alias '{source_alias}' to '{destination_relative_path}' in external_configs."
|
| 494 |
+
}
|
| 495 |
+
```
|
| 496 |
+
* **Error Responses:**
|
| 497 |
+
* 400 Bad Request: If the source alias doesn't point to a `.yaml` file, if destination path is invalid, or if destination parent directory doesn't exist.
|
| 498 |
+
* 404 Not Found: If the source alias is not found in `super_config.yaml` or the resolved source file doesn't exist.
|
| 499 |
+
* 409 Conflict: If the destination path already exists.
|
| 500 |
+
* 500 Internal Server Error: If resolving paths, reading the source, or writing the destination file fails.
|
atk-interface/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## Expanding the ESLint configuration
|
| 11 |
+
|
| 12 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
atk-interface/api_README.md
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Augmentoolkit API Documentation
|
| 2 |
+
|
| 3 |
+
This document describes the REST API endpoints for interacting with the Augmentoolkit server, allowing you to manage and run data generation pipelines, monitor their status, and manage associated files (inputs, outputs, logs, configs).
|
| 4 |
+
|
| 5 |
+
The API is built using FastAPI and utilizes Huey for background task queueing and Redis for state management (task status, PID mapping, output directory mapping).
|
| 6 |
+
|
| 7 |
+
**Base URL:** (Assuming the server runs locally on port 8000) `http://127.0.0.1:8000`
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Core Concepts
|
| 12 |
+
|
| 13 |
+
* **Pipelines:** Defined sequences of data processing steps, typically corresponding to Python functions within the codebase (e.g., in a `pipelines` directory).
|
| 14 |
+
* **Tasks:** An instance of a pipeline run, managed by the Huey background task queue. Each task has a unique `task_id`.
|
| 15 |
+
* **Nodes:** Refers to the importable path of the Python function that executes a pipeline (e.g., `pipelines.my_pipeline.run`).
|
| 16 |
+
* **Configs:** YAML configuration files (stored in `./external_configs/`) that provide parameters for pipeline runs. Can be overridden by parameters in the API request.
|
| 17 |
+
* **Super Config (`super_config.yaml`):** Defines path aliases used for resolving node and config paths.
|
| 18 |
+
* **Directories:**
|
| 19 |
+
* `./inputs/`: Stores input data files used by pipelines.
|
| 20 |
+
* `./outputs/`: Stores output files generated by pipelines. Output directories are often named using the `task_id`.
|
| 21 |
+
* `./external_configs/`: Stores reusable pipeline configuration files.
|
| 22 |
+
* `./logs/`: Stores log files (`<task_id>.log`) for each pipeline task run.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Endpoints
|
| 27 |
+
|
| 28 |
+
### Health Check
|
| 29 |
+
|
| 30 |
+
* **GET /**
|
| 31 |
+
* **Summary:** Health Check
|
| 32 |
+
* **Description:** Basic health check endpoint. Returns a simple message indicating the API is running.
|
| 33 |
+
* **Response:** `{"message": "Augmentoolkit API is running."}`
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
### Pipeline Execution
|
| 38 |
+
|
| 39 |
+
* **POST /pipelines/run**
|
| 40 |
+
* **Summary:** Queue a dataset generation pipeline for execution.
|
| 41 |
+
* **Status Code:** 202 Accepted
|
| 42 |
+
* **Description:** Resolves node and config paths using aliases from `super_config.yaml` and enqueues a pipeline task using Huey (`tasks.run_pipeline_task`). The task executes the pipeline defined by `node_path` potentially using configuration from `config_path` and `parameters` via `run_augmentoolkit.py`.
|
| 43 |
+
* **Request Body:**
|
| 44 |
+
```json
|
| 45 |
+
{
|
| 46 |
+
"node_path": "string (required, can use aliases)",
|
| 47 |
+
"config_path": "string (optional, relative to ./external_configs/ or absolute, can use aliases)",
|
| 48 |
+
"parameters": { /* JSON object of key-value pairs (optional) */ }
|
| 49 |
+
}
|
| 50 |
+
```
|
| 51 |
+
* **Response:**
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"pipeline_id": "string (Huey Task ID)",
|
| 55 |
+
"message": "Pipeline run queued successfully."
|
| 56 |
+
}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
* **GET /pipelines/available**
|
| 60 |
+
* **Summary:** Get available pipeline aliases from super_config.yaml.
|
| 61 |
+
* **Description:** Reads the `super_config.yaml` file and returns a sorted list of path aliases (keys) whose corresponding values do not end with `.yaml`. These aliases typically represent the runnable entry points for pipelines that can be passed to the `node_path` field in the `/pipelines/run` endpoint.
|
| 62 |
+
* **Path Parameters:** None
|
| 63 |
+
* **Query Parameters:** None
|
| 64 |
+
* **Response:** A JSON list of strings.
|
| 65 |
+
```json
|
| 66 |
+
[
|
| 67 |
+
"alias1",
|
| 68 |
+
"alias2",
|
| 69 |
+
"another-pipeline-alias",
|
| 70 |
+
...
|
| 71 |
+
]
|
| 72 |
+
```
|
| 73 |
+
* **Error Responses:**
|
| 74 |
+
* 500 Internal Server Error: If `super_config.yaml` cannot be found or parsed, or an unexpected error occurs. (Note: If the file is found but empty or `path_aliases` is missing/invalid, it currently returns an empty list `[]` gracefully).
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
### Task Status & Management
|
| 79 |
+
|
| 80 |
+
* **GET /tasks/{task_id}/status**
|
| 81 |
+
* **Summary:** Get the status of a pipeline run.
|
| 82 |
+
* **Description:** Retrieves the current status (PENDING, RUNNING, COMPLETED, FAILED, REVOKED) and result (if finished) of a specific pipeline task using its `task_id`. Checks Huey's state and potentially Redis for progress information.
|
| 83 |
+
* **Path Parameters:**
|
| 84 |
+
* `task_id`: The Huey task ID obtained from `/pipelines/run`.
|
| 85 |
+
* **Response:**
|
| 86 |
+
```json
|
| 87 |
+
{
|
| 88 |
+
"task_id": "string",
|
| 89 |
+
"status": "string (PENDING | RUNNING | COMPLETED | FAILED | REVOKED)",
|
| 90 |
+
"message": "string (optional)",
|
| 91 |
+
"progress": "float (optional, 0.0-1.0)",
|
| 92 |
+
"details": { /* Optional details, e.g., error info or result */ }
|
| 93 |
+
}
|
| 94 |
+
```
|
| 95 |
+
* **Error Responses:**
|
| 96 |
+
* 404 Not Found: If `task_id` does not exist.
|
| 97 |
+
* 500 Internal Server Error: If status retrieval fails.
|
| 98 |
+
|
| 99 |
+
* **GET /tasks/{task_id}/parameters**
|
| 100 |
+
* **Summary:** Get the parameters a task was executed with.
|
| 101 |
+
* **Description:** Retrieves the parameters (combined from config file and request overrides) that were used to start a specific task run. Parameters are stored in Redis when the task begins execution and persist based on a configured timeout (typically several days).
|
| 102 |
+
* **Path Parameters:**
|
| 103 |
+
* `task_id`: The Huey task ID obtained from `/pipelines/run`.
|
| 104 |
+
* **Response:**
|
| 105 |
+
```json
|
| 106 |
+
{
|
| 107 |
+
"task_id": "string",
|
| 108 |
+
"parameters": { /* JSON object of key-value pairs used for the task */ }
|
| 109 |
+
}
|
| 110 |
+
```
|
| 111 |
+
* **Error Responses:**
|
| 112 |
+
* 404 Not Found: If the `task_id` does not exist or if the parameters were not stored/have expired.
|
| 113 |
+
* 500 Internal Server Error: If retrieving or parsing the parameters from Redis fails (e.g., data corruption).
|
| 114 |
+
|
| 115 |
+
* **POST /tasks/{task_id}/interrupt**
|
| 116 |
+
* **Summary:** Interrupt a running pipeline task subprocess or revoke a pending task.
|
| 117 |
+
* **Description:** Attempts to interrupt a running task or revoke a pending one.
|
| 118 |
+
* If the task is **RUNNING**: Looks up the subprocess PID associated with the `task_id` in Redis (set by `tasks.py`) and sends a `SIGINT` signal to it using `os.kill`.
|
| 119 |
+
* If the task is **PENDING**: Revokes the task using `huey.revoke`.
|
| 120 |
+
* Reports an error if the task is already finished or revoked.
|
| 121 |
+
* **Path Parameters:**
|
| 122 |
+
* `task_id`: The Huey task ID.
|
| 123 |
+
* **Response:**
|
| 124 |
+
* `{"message": "Sent SIGINT signal..."}` (for running tasks)
|
| 125 |
+
* `{"message": "Task ... was pending and has been successfully revoked."}` (for pending tasks)
|
| 126 |
+
* **Error Responses:**
|
| 127 |
+
* 404 Not Found: If `task_id` does not exist or the subprocess PID is gone.
|
| 128 |
+
* 409 Conflict: If the task is already finished/revoked or fails to revoke.
|
| 129 |
+
* 500 Internal Server Error: If signaling fails or other errors occur.
|
| 130 |
+
|
| 131 |
+
* **GET /tasks/queue**
|
| 132 |
+
* **Summary:** Get lists of pending and scheduled tasks.
|
| 133 |
+
* **Description:** Retrieves the IDs of tasks currently pending or scheduled in the Huey queue using `huey.pending()` and `huey.scheduled()`. Note: This does not reliably show tasks that are *actively running*.
|
| 134 |
+
* **Path Parameters:** None
|
| 135 |
+
* **Query Parameters:** None
|
| 136 |
+
* **Response:**
|
| 137 |
+
```json
|
| 138 |
+
{
|
| 139 |
+
"pending_tasks": ["string (task_id)", ...],
|
| 140 |
+
"scheduled_tasks": ["string (task_id)", ...],
|
| 141 |
+
"message": "string (e.g., Found X pending and Y scheduled tasks.)"
|
| 142 |
+
}
|
| 143 |
+
```
|
| 144 |
+
* **Error Responses:**
|
| 145 |
+
* 500 Internal Server Error: If retrieving queue status fails.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
### Log Management
|
| 150 |
+
|
| 151 |
+
* **GET /tasks/{task_id}/logs**
|
| 152 |
+
* **Summary:** Get logs for a specific task.
|
| 153 |
+
* **Description:** Retrieves the content of the log file (`./logs/<task_id>.log`) generated by a specific task run.
|
| 154 |
+
* **Path Parameters:**
|
| 155 |
+
* `task_id`: The Huey task ID.
|
| 156 |
+
* **Query Parameters:**
|
| 157 |
+
* `tail` (int, optional): Return only the last N lines of the log.
|
| 158 |
+
* **Response:**
|
| 159 |
+
```json
|
| 160 |
+
{
|
| 161 |
+
"task_id": "string",
|
| 162 |
+
"message": "string",
|
| 163 |
+
"logs": "string (log content)"
|
| 164 |
+
}
|
| 165 |
+
```
|
| 166 |
+
* **Error Responses:**
|
| 167 |
+
* 404 Not Found: If the log file doesn't exist.
|
| 168 |
+
* 500 Internal Server Error: If reading the file fails.
|
| 169 |
+
|
| 170 |
+
* **DELETE /tasks/{task_id}/logs**
|
| 171 |
+
* **Summary:** Delete log file for a specific task.
|
| 172 |
+
* **Description:** Deletes the specific log file (`./logs/<task_id>.log`).
|
| 173 |
+
* **Path Parameters:**
|
| 174 |
+
* `task_id`: The Huey task ID.
|
| 175 |
+
* **Response:** `{"message": "Successfully deleted log file..."}`
|
| 176 |
+
* **Error Responses:**
|
| 177 |
+
* 404 Not Found: If the log file doesn't exist.
|
| 178 |
+
* 500 Internal Server Error: If deletion fails.
|
| 179 |
+
|
| 180 |
+
* **GET /logs**
|
| 181 |
+
* **Summary:** List all available log files.
|
| 182 |
+
* **Description:** Returns a list of all `.log` filenames currently in the `./logs/` directory.
|
| 183 |
+
* **Response:** `{"log_files": ["task_id1.log", "task_id2.log", ...]}`
|
| 184 |
+
* **Error Responses:**
|
| 185 |
+
* 500 Internal Server Error: If listing the directory fails.
|
| 186 |
+
|
| 187 |
+
* **DELETE /logs**
|
| 188 |
+
* **Summary:** Clear all task log files.
|
| 189 |
+
* **Description:** Deletes all files ending with `.log` within the `./logs/` directory.
|
| 190 |
+
* **Response:** Reports the number of deleted files and any errors encountered.
|
| 191 |
+
```json
|
| 192 |
+
{
|
| 193 |
+
"message": "Successfully deleted X log file(s)...",
|
| 194 |
+
"errors": [ /* list of error strings, if any */ ]
|
| 195 |
+
}
|
| 196 |
+
```
|
| 197 |
+
* **Error Responses:**
|
| 198 |
+
* 500 Internal Server Error: If accessing or clearing the directory fails.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
### Output File Management
|
| 203 |
+
|
| 204 |
+
* **GET /tasks/{task_id}/outputs/download**
|
| 205 |
+
* **Summary:** Download output directory for a task.
|
| 206 |
+
* **Description:** Retrieves the path of the output directory associated with the `task_id` from Redis (set by `tasks.py`), zips the entire directory content, and returns it as a downloadable file (`task_<task_id>_output.zip`).
|
| 207 |
+
* **Path Parameters:**
|
| 208 |
+
* `task_id`: The Huey task ID.
|
| 209 |
+
* **Response:** A zip file (`application/zip`).
|
| 210 |
+
* **Error Responses:**
|
| 211 |
+
* 404 Not Found: If the task ID mapping or the directory itself doesn't exist.
|
| 212 |
+
* 500 Internal Server Error: If zipping or file access fails.
|
| 213 |
+
|
| 214 |
+
* **GET /tasks/{task_id}/outputs/structure**
|
| 215 |
+
* **Summary:** Get structure of a task's output directory.
|
| 216 |
+
* **Description:** Retrieves the path of the output directory associated with the `task_id` from Redis and returns its file/folder structure as a JSON list. Paths are relative to the specific task's output directory root.
|
| 217 |
+
* **Path Parameters:**
|
| 218 |
+
* `task_id`: The Huey task ID.
|
| 219 |
+
* **Response:** List of `FileStructure` objects (see below).
|
| 220 |
+
* **Error Responses:**
|
| 221 |
+
* 404 Not Found: If the task ID mapping or the directory itself doesn't exist.
|
| 222 |
+
* 500 Internal Server Error: If reading the directory fails.
|
| 223 |
+
|
| 224 |
+
* **GET /outputs/structure/{relative_path:path}**
|
| 225 |
+
* **Summary:** Get structure of a path within the main outputs directory.
|
| 226 |
+
* **Description:** Retrieves the file/folder structure for a given path relative to the root `./outputs/` directory. Uses the shared `handle_get_structure` helper.
|
| 227 |
+
* **Path Parameters:**
|
| 228 |
+
* `relative_path`: The path relative to `./outputs/` (e.g., `my_folder/sub_folder` or leave empty/`.` for the root).
|
| 229 |
+
* **Response:** List of `FileStructure` objects:
|
| 230 |
+
```json
|
| 231 |
+
[
|
| 232 |
+
{
|
| 233 |
+
"path": "string (relative path)",
|
| 234 |
+
"is_dir": true,
|
| 235 |
+
"children": [ /* nested FileStructure objects */ ]
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"path": "string (relative path)",
|
| 239 |
+
"is_dir": false,
|
| 240 |
+
"children": null
|
| 241 |
+
}
|
| 242 |
+
]
|
| 243 |
+
```
|
| 244 |
+
* **Error Responses:**
|
| 245 |
+
* 404 Not Found: If the path doesn't exist.
|
| 246 |
+
* 400 Bad Request: If the path is not a directory.
|
| 247 |
+
* 500 Internal Server Error: If listing fails.
|
| 248 |
+
|
| 249 |
+
* **GET /outputs/download/{relative_path:path}**
|
| 250 |
+
* **Summary:** Download a specific file or folder from outputs.
|
| 251 |
+
* **Description:** Downloads a specific file or zips and downloads a specific folder relative to the root `./outputs/` directory. Uses the shared `handle_download_item` helper.
|
| 252 |
+
* **Path Parameters:**
|
| 253 |
+
* `relative_path`: The path relative to `./outputs/`.
|
| 254 |
+
* **Response:** A file (`application/octet-stream` or detected type) or a zip archive (`application/zip`).
|
| 255 |
+
* **Error Responses:**
|
| 256 |
+
* 404 Not Found: If the path doesn't exist.
|
| 257 |
+
* 500 Internal Server Error: If reading/zipping fails.
|
| 258 |
+
|
| 259 |
+
* **DELETE /outputs/{relative_path:path}**
|
| 260 |
+
* **Summary:** Delete a file or folder from outputs.
|
| 261 |
+
* **Description:** Deletes a specific file or folder relative to the root `./outputs/` directory. Uses the shared `handle_delete_item` helper. **Warning:** This is permanent.
|
| 262 |
+
* **Path Parameters:**
|
| 263 |
+
* `relative_path`: The path relative to `./outputs/`.
|
| 264 |
+
* **Response:** `{"message": "Successfully deleted..."}`
|
| 265 |
+
* **Error Responses:**
|
| 266 |
+
* 404 Not Found: If the path doesn't exist.
|
| 267 |
+
* 400 Bad Request: If attempting to delete the root `./outputs` directory.
|
| 268 |
+
* 500 Internal Server Error: If deletion fails.
|
| 269 |
+
|
| 270 |
+
* **POST /outputs/move**
|
| 271 |
+
* **Summary:** Move/Rename a file or folder within outputs.
|
| 272 |
+
* **Description:** Moves or renames a file or folder within the `./outputs/` directory using `shutil.move`.
|
| 273 |
+
* The source path must exist.
|
| 274 |
+
* The destination path must *not* already exist (to prevent overwrites).
|
| 275 |
+
* The parent directory of the destination path must exist.
|
| 276 |
+
* Cannot be used to move the root `./outputs` directory itself.
|
| 277 |
+
* **Request Body:**
|
| 278 |
+
```json
|
| 279 |
+
{
|
| 280 |
+
"source_relative_path": "string (required, current relative path)",
|
| 281 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 282 |
+
}
|
| 283 |
+
```
|
| 284 |
+
* **Response (Status 200):**
|
| 285 |
+
```json
|
| 286 |
+
{
|
| 287 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 288 |
+
}
|
| 289 |
+
```
|
| 290 |
+
* **Error Responses:**
|
| 291 |
+
* 400 Bad Request: If source/destination paths are invalid, if the destination parent doesn't exist, or if attempting to move the root directory.
|
| 292 |
+
* 404 Not Found: If the source path does not exist.
|
| 293 |
+
* 409 Conflict: If the destination path already exists.
|
| 294 |
+
* 500 Internal Server Error: If the move operation fails for other reasons (e.g., permissions).
|
| 295 |
+
|
| 296 |
+
---
|
| 297 |
+
|
| 298 |
+
### Input File Management
|
| 299 |
+
|
| 300 |
+
*Input file management endpoints mirror the Output File Management endpoints, but operate on the `./inputs/` directory.*
|
| 301 |
+
|
| 302 |
+
* **GET /inputs/structure/{relative_path:path}**
|
| 303 |
+
* **Summary:** Get structure of a path within the inputs directory.
|
| 304 |
+
* **Description:** Retrieves structure relative to `./inputs/`.
|
| 305 |
+
* **Response:** List of `FileStructure` objects.
|
| 306 |
+
|
| 307 |
+
* **GET /inputs/download/{relative_path:path}**
|
| 308 |
+
* **Summary:** Download a specific file or folder from inputs.
|
| 309 |
+
* **Description:** Downloads file/folder relative to `./inputs/`.
|
| 310 |
+
* **Response:** File or Zip archive.
|
| 311 |
+
|
| 312 |
+
* **DELETE /inputs/{relative_path:path}**
|
| 313 |
+
* **Summary:** Delete a file or folder from inputs.
|
| 314 |
+
* **Description:** Deletes file/folder relative to `./inputs/`. **Warning:** Permanent.
|
| 315 |
+
* **Response:** `{"message": "Successfully deleted..."}`
|
| 316 |
+
|
| 317 |
+
* **POST /inputs/move**
|
| 318 |
+
* **Summary:** Move/Rename a file or folder within inputs.
|
| 319 |
+
* **Description:** Moves or renames a file or folder within the `./inputs/` directory using `shutil.move`. See `/outputs/move` for detailed behavior and error conditions.
|
| 320 |
+
* **Request Body:**
|
| 321 |
+
```json
|
| 322 |
+
{
|
| 323 |
+
"source_relative_path": "string (required, current relative path)",
|
| 324 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 325 |
+
}
|
| 326 |
+
```
|
| 327 |
+
* **Response (Status 200):**
|
| 328 |
+
```json
|
| 329 |
+
{
|
| 330 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 331 |
+
}
|
| 332 |
+
```
|
| 333 |
+
* **Error Responses:** 400, 404, 409, 500 (see `/outputs/move`)
|
| 334 |
+
|
| 335 |
+
* **POST /inputs/directory**
|
| 336 |
+
* **Summary:** Create a new directory within the inputs folder.
|
| 337 |
+
* **Description:** Creates a new directory at the specified `relative_path` within the `./inputs/` directory. The path can include parent directories (e.g., `existing_folder/new_subfolder`).
|
| 338 |
+
* **Request Body:**
|
| 339 |
+
```json
|
| 340 |
+
{
|
| 341 |
+
"relative_path": "string (required, relative path)"
|
| 342 |
+
}
|
| 343 |
+
```
|
| 344 |
+
* **Response (Status 201):**
|
| 345 |
+
```json
|
| 346 |
+
{
|
| 347 |
+
"message": "Successfully created directory '{relative_path}' in inputs."
|
| 348 |
+
}
|
| 349 |
+
```
|
| 350 |
+
* **Error Responses:**
|
| 351 |
+
* 400 Bad Request: If `relative_path` is invalid (empty, '.', targets root) or if the parent directory specified in the path does not exist.
|
| 352 |
+
* 409 Conflict: If a file or directory already exists at the target `relative_path`.
|
| 353 |
+
* 500 Internal Server Error: If directory creation fails for other reasons (e.g., permissions).
|
| 354 |
+
|
| 355 |
+
* **POST /inputs/upload/{relative_path:path}**
|
| 356 |
+
* **Summary:** Upload files to a specific path within inputs, automatically extracting zip files.
|
| 357 |
+
* **Description:** Uploads one or more files to a specified path within `./inputs/`. The path is created if it doesn't exist.
|
| 358 |
+
* If a file ends with `.zip` (case-insensitive), it is automatically extracted into the target `relative_path`.
|
| 359 |
+
* Upon successful extraction, the original `.zip` file is deleted.
|
| 360 |
+
* If extraction fails, the original `.zip` file is kept, and an error is reported.
|
| 361 |
+
* Non-zip files are saved directly to the `relative_path`.
|
| 362 |
+
* Allows uploading directly to the root `./inputs/` directory (e.g., by specifying `.` as the `relative_path`).
|
| 363 |
+
* **Path Parameters:**
|
| 364 |
+
* `relative_path`: Target path relative to `./inputs/`. Files (including extracted zip contents) will be placed here.
|
| 365 |
+
* **Request Body:** `multipart/form-data` containing one or more files.
|
| 366 |
+
* **Response (Status 201 or 207):**
|
| 367 |
+
```json
|
| 368 |
+
{
|
| 369 |
+
"message": "Upload process completed...",
|
| 370 |
+
"uploaded_files": [
|
| 371 |
+
{"filename": "string", "size": int, "path": "string (relative path)", "error": "string (optional, only on failed extraction)"}
|
| 372 |
+
],
|
| 373 |
+
"extracted_zips": [
|
| 374 |
+
{"filename": "string (original zip)", "extracted_to": "string (relative path)"}
|
| 375 |
+
],
|
| 376 |
+
"errors": [ /* list of general error strings, if any */ ]
|
| 377 |
+
}
|
| 378 |
+
```
|
| 379 |
+
* **Error Responses:**
|
| 380 |
+
* 500 Internal Server Error: If directory creation, file writing, or extraction fails unexpectedly.
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
### Config File Management
|
| 385 |
+
|
| 386 |
+
*Configuration file management endpoints operate on the `./external_configs/` directory.*
|
| 387 |
+
|
| 388 |
+
* **GET /configs/aliases**
|
| 389 |
+
* **Summary:** Get available config file aliases from super_config.yaml.
|
| 390 |
+
* **Description:** Reads the `super_config.yaml` file and returns a sorted list of path aliases (keys) whose corresponding values *do* end with `.yaml` (case-insensitive). These aliases can be used to identify existing configuration files for duplication or other purposes.
|
| 391 |
+
* **Path Parameters:** None
|
| 392 |
+
* **Query Parameters:** None
|
| 393 |
+
* **Response:** A JSON list of strings.
|
| 394 |
+
```json
|
| 395 |
+
[
|
| 396 |
+
"config-alias1",
|
| 397 |
+
"another_config.yaml",
|
| 398 |
+
"path/to/config_alias_3",
|
| 399 |
+
...
|
| 400 |
+
]
|
| 401 |
+
```
|
| 402 |
+
* **Error Responses:**
|
| 403 |
+
* 500 Internal Server Error: If `super_config.yaml` cannot be found or parsed, or an unexpected error occurs. (Note: If the file is found but empty or `path_aliases` is missing/invalid, it returns an empty list `[]` gracefully).
|
| 404 |
+
|
| 405 |
+
* **GET /configs/structure/{relative_path:path}**
|
| 406 |
+
* **Summary:** Get structure of a path within the configs directory.
|
| 407 |
+
* **Description:** Retrieves structure relative to `./external_configs/`.
|
| 408 |
+
* **Response:** List of `FileStructure` objects.
|
| 409 |
+
|
| 410 |
+
* **GET /configs/content/{relative_path:path}**
|
| 411 |
+
* **Summary:** Get content of a specific config file.
|
| 412 |
+
* **Description:** Retrieves the plain text content of a specific file relative to `./external_configs/`.
|
| 413 |
+
* **Path Parameters:**
|
| 414 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 415 |
+
* **Response:** Plain text file content (`text/plain`).
|
| 416 |
+
* **Error Responses:**
|
| 417 |
+
* 404 Not Found: If the file doesn't exist.
|
| 418 |
+
* 400 Bad Request: If the path is not a file.
|
| 419 |
+
|
| 420 |
+
* **POST /configs/content/{relative_path:path}**
|
| 421 |
+
* **Summary:** Create or update a config file.
|
| 422 |
+
* **Description:** Creates a new file or overwrites an existing file at the specified path relative to `./external_configs/`. Parent directories are created if they don't exist.
|
| 423 |
+
* **Path Parameters:**
|
| 424 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 425 |
+
* **Request Body:** Raw text content (`text/plain`).
|
| 426 |
+
* **Response:** `{"message": "Successfully saved config..."}`
|
| 427 |
+
* **Error Responses:**
|
| 428 |
+
* 400 Bad Request: If attempting to write to the root.
|
| 429 |
+
* 500 Internal Server Error: If writing fails.
|
| 430 |
+
|
| 431 |
+
* **DELETE /configs/{relative_path:path}**
|
| 432 |
+
* **Summary:** Delete a specific config file or directory.
|
| 433 |
+
* **Description:** Deletes a specific file or directory (recursively) relative to `./external_configs/`. **Warning:** Permanent.
|
| 434 |
+
* **Path Parameters:**
|
| 435 |
+
* `relative_path`: The path relative to `./external_configs/`.
|
| 436 |
+
* **Response:** `{"message": "Successfully deleted config {file|directory}..."}`
|
| 437 |
+
* **Error Responses:**
|
| 438 |
+
* 404 Not Found: If the file or directory doesn't exist.
|
| 439 |
+
* 400 Bad Request: If attempting to delete the root `./external_configs` directory.
|
| 440 |
+
* 500 Internal Server Error: If deletion fails.
|
| 441 |
+
|
| 442 |
+
* **POST /configs/move**
|
| 443 |
+
* **Summary:** Move/Rename a file or folder within configs.
|
| 444 |
+
* **Description:** Moves or renames a file or folder within the `./external_configs/` directory using `shutil.move`. See `/outputs/move` for detailed behavior and error conditions.
|
| 445 |
+
* **Request Body:**
|
| 446 |
+
```json
|
| 447 |
+
{
|
| 448 |
+
"source_relative_path": "string (required, current relative path)",
|
| 449 |
+
"destination_relative_path": "string (required, new relative path)"
|
| 450 |
+
}
|
| 451 |
+
```
|
| 452 |
+
* **Response (Status 200):**
|
| 453 |
+
```json
|
| 454 |
+
{
|
| 455 |
+
"message": "Successfully moved {file/directory} from '{source}' to '{destination}'."
|
| 456 |
+
}
|
| 457 |
+
```
|
| 458 |
+
* **Error Responses:** 400, 404, 409, 500 (see `/outputs/move`)
|
| 459 |
+
|
| 460 |
+
* **POST /configs/directory**
|
| 461 |
+
* **Summary:** Create a new directory within the configs folder.
|
| 462 |
+
* **Description:** Creates a new directory at the specified `relative_path` within the `./external_configs/` directory. Uses the shared `handle_create_directory` helper.
|
| 463 |
+
* **Request Body:**
|
| 464 |
+
```json
|
| 465 |
+
{
|
| 466 |
+
"relative_path": "string (required, relative path)"
|
| 467 |
+
}
|
| 468 |
+
```
|
| 469 |
+
* **Response (Status 201):**
|
| 470 |
+
```json
|
| 471 |
+
{
|
| 472 |
+
"message": "Successfully created directory '{relative_path}' in external_configs."
|
| 473 |
+
}
|
| 474 |
+
```
|
| 475 |
+
* **Error Responses:**
|
| 476 |
+
* 400 Bad Request: If `relative_path` is invalid (empty, '.', targets root) or if the parent directory specified in the path does not exist.
|
| 477 |
+
* 409 Conflict: If a file or directory already exists at the target `relative_path`.
|
| 478 |
+
* 500 Internal Server Error: If directory creation fails for other reasons (e.g., permissions).
|
| 479 |
+
|
| 480 |
+
* **POST /configs/duplicate**
|
| 481 |
+
* **Summary:** Duplicate a pipeline config into external_configs.
|
| 482 |
+
* **Description:** Duplicates a configuration file identified by a `source_alias` (from `super_config.yaml`) into the `./external_configs/` directory at the specified `destination_relative_path`. The source alias must resolve to an existing `.yaml` file. The destination path must not already exist, and its parent directory must exist.
|
| 483 |
+
* **Request Body:**
|
| 484 |
+
```json
|
| 485 |
+
{
|
| 486 |
+
"source_alias": "string (required, alias from super_config.yaml)",
|
| 487 |
+
"destination_relative_path": "string (required, new relative path in external_configs)"
|
| 488 |
+
}
|
| 489 |
+
```
|
| 490 |
+
* **Response (Status 201):**
|
| 491 |
+
```json
|
| 492 |
+
{
|
| 493 |
+
"message": "Successfully duplicated config from alias '{source_alias}' to '{destination_relative_path}' in external_configs."
|
| 494 |
+
}
|
| 495 |
+
```
|
| 496 |
+
* **Error Responses:**
|
| 497 |
+
* 400 Bad Request: If the source alias doesn't point to a `.yaml` file, if destination path is invalid, or if destination parent directory doesn't exist.
|
| 498 |
+
* 404 Not Found: If the source alias is not found in `super_config.yaml` or the resolved source file doesn't exist.
|
| 499 |
+
* 409 Conflict: If the destination path already exists.
|
| 500 |
+
* 500 Internal Server Error: If resolving paths, reading the source, or writing the destination file fails.
|
atk-interface/eslint.config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
|
| 6 |
+
export default [
|
| 7 |
+
{ ignores: ['dist'] },
|
| 8 |
+
{
|
| 9 |
+
files: ['**/*.{js,jsx}'],
|
| 10 |
+
languageOptions: {
|
| 11 |
+
ecmaVersion: 2020,
|
| 12 |
+
globals: globals.browser,
|
| 13 |
+
parserOptions: {
|
| 14 |
+
ecmaVersion: 'latest',
|
| 15 |
+
ecmaFeatures: { jsx: true },
|
| 16 |
+
sourceType: 'module',
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
plugins: {
|
| 20 |
+
'react-hooks': reactHooks,
|
| 21 |
+
'react-refresh': reactRefresh,
|
| 22 |
+
},
|
| 23 |
+
rules: {
|
| 24 |
+
...js.configs.recommended.rules,
|
| 25 |
+
...reactHooks.configs.recommended.rules,
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
'react-refresh/only-export-components': [
|
| 28 |
+
'warn',
|
| 29 |
+
{ allowConstantExport: true },
|
| 30 |
+
],
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
]
|
atk-interface/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/png" href="/src/assets/atk-logo-notscuffed.png" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Augmentoolkit</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
atk-interface/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
atk-interface/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "atk-interface",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@monaco-editor/react": "^4.7.0",
|
| 14 |
+
"file-saver": "^2.0.5",
|
| 15 |
+
"framer-motion": "^12.7.4",
|
| 16 |
+
"jszip": "^3.10.1",
|
| 17 |
+
"react": "^19.0.0",
|
| 18 |
+
"react-arborist": "^3.4.3",
|
| 19 |
+
"react-dom": "^19.0.0",
|
| 20 |
+
"react-dropzone": "^14.3.8",
|
| 21 |
+
"react-icons": "^5.5.0",
|
| 22 |
+
"react-router-dom": "^7.5.1"
|
| 23 |
+
},
|
| 24 |
+
"devDependencies": {
|
| 25 |
+
"@eslint/js": "^9.22.0",
|
| 26 |
+
"@types/react": "^19.0.10",
|
| 27 |
+
"@types/react-dom": "^19.0.4",
|
| 28 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 29 |
+
"autoprefixer": "^10.4.21",
|
| 30 |
+
"eslint": "^9.22.0",
|
| 31 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
| 32 |
+
"eslint-plugin-react-refresh": "^0.4.19",
|
| 33 |
+
"globals": "^16.0.0",
|
| 34 |
+
"postcss": "^8.5.3",
|
| 35 |
+
"tailwindcss": "^3.4.17",
|
| 36 |
+
"vite": "^6.3.1"
|
| 37 |
+
}
|
| 38 |
+
}
|
atk-interface/pages_README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This codebase will contain an interface for interacting with a locally-running Augmentoolkit server. The structure of the Augmentoolkit server's API is described in api_README.md.
|
| 2 |
+
|
| 3 |
+
A basic sketch of pages is laid out below:
|
| 4 |
+
pages are probably
|
| 5 |
+
1. navigation. Configs, Inputs, and Outputs? And pipeline execution babysitting.
|
| 6 |
+
2. Config editing. Modify config fields directly, oh and if you point input_dir or output_dir at something that does not exist, it warns you.
|
| 7 |
+
- this page will have the ability for executing a pipeline too.
|
| 8 |
+
3. Input editing. Upload files, view what files are there, delete files. Main thing is sorta like a filebrowser over the structure we get back as a result. Download button on the selected item, alternatively shift click or drag select and download all selected items, everything will feel nice and snappy.
|
| 9 |
+
4. Output editing. Same operations as inputs plus it shows a list of all task IDs this session and lets you navigate to any output dir from there.
|
| 10 |
+
5. pipeline execution babysitting! We have this big progress bar at the top as well as an expandable details box off to the side showing (read only) the config we ran with with syntax highlighting. details key will include execution stats, that's what I'll use that reserved field for -- and the interface will proudly display whatever key-value pairs are added to the details as part of pipeline execution. It polls details and progress once a second when this screen is on (single page app you can click between screens and go back to the last one and it will be where you left off on it). A big download output dir button. A big cancel execution button with a confirm modal. And in a window, also updating (maybe once every .5 seconds or less) we have the logs streaming in (efficiently, using the cursor).
|
| 11 |
+
|
| 12 |
+
Some design brainstorming is below:
|
| 13 |
+
Tactile feedback and style:
|
| 14 |
+
1. dark mode maybe gradient for modern feel. Motion/replaying things in the background, maybe just moving shapes, maybe something else that exudes AI techological feel like light effects against a dark background, falling symbols or whatever, just give the ADD brains something to be mesmerized by during lulls. Idea: not falling symbols. There'll be one or mulitple beads of light travelling along the paths created by a diagram of a neural network. You know, the thing with all the nodes interconnected with lines? Would allow for a lot of variation, is topical, is hopefully easy to code? Hmm.)
|
| 15 |
+
2. buttons shake and move when pressed and generally satisfying
|
| 16 |
+
3. highlights, different color based on actions
|
| 17 |
+
4. stylize things like the drag select, download, modals, make you feel the actions
|
| 18 |
+
5. make errors less painful by cheerful presentation while displaying all important information.
|
| 19 |
+
6. Rounded corners etc.
|
| 20 |
+
7. When datagen runs finish, the mood is triumphant.
|
| 21 |
+
8. High numbers in the details box change its color and effects?
|
| 22 |
+
9. Particle effects?
|
| 23 |
+
10. text boxes smooth and elegant Whatever makes typing seem smoother or whatever, more fluid and flowing, do that. Colored typing cursor?
|
| 24 |
+
11. and have a "boring mode" toggle in the 6. settings page that disables all the fun stuff.
|
| 25 |
+
12. actions, when taken, should highlight/draw attention to the natural next action through design. When a dataset generation run finishes, the "download output dir" (or however it is more concisely worded) button should react, be highlighted, appealing, etc. When files are selected in the input or output editing pages (such as potentially for downloading) the download button reacts. If a config is being edited, and the input_dir or output_dir is pointing to something which, we can tell from the structure of those does not exist, and the interface is warning you, the "run this config" should be less obvious or imply that it is a bad idea. This makes using the interface self-explanatory and easier and more fun and also makes doing so more engaging.
|
| 26 |
+
|
| 27 |
+
### Specific tools we will use to achieve visual impact: ###
|
| 28 |
+
Particle effects
|
| 29 |
+
coloration changes based on page and activity
|
| 30 |
+
Lighting following interaction
|
| 31 |
+
3d objects that move with the mouse
|
| 32 |
+
Shaking background for important actions
|
| 33 |
+
Background fades for transition from one page to another
|
| 34 |
+
...more...
|
| 35 |
+
|
| 36 |
+
NOTE things like particle effects, which are cool but are unusual for this sort of thing, will get used only for the MOST important things (like progress bar) so that we don't jar/look pretentious and overdesigned/so that it remains tasteful
|
| 37 |
+
|
| 38 |
+
###
|
| 39 |
+
|
| 40 |
+
General actions:
|
| 41 |
+
modals autofocus the first text box and each step in a modal guides you to the next
|
| 42 |
+
new files that are created should be highlighted until some action is taken on the page
|
| 43 |
+
IF you can't tell the effect is there, the effect is either extraneous and should be removed, or should be strengthened
|
| 44 |
+
too many effects (or too many effects in different places) when an action is taken is confusing. Draw their attention to one place (see the or double click to open OVER THE DELETE BUTTON in configs management.
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
Everything should be very performant to keep the project fast to clone and set up. No large clunky videos in the background playing on loop -- generate things. Maybe we can use a simple web physics thing or lighting thing or whatever -- think like an svg over a png, we will make the most excessive style things be done in elegant ways, code- and space-wise.
|
| 48 |
+
|
| 49 |
+
Style thoughts:
|
| 50 |
+
We're going to make this nice to look at, engaging and stylistic, and make it give feedback on the basic actions in order to make the work more engaging, fun, and viral. We're going to take lessons from game mechanics and make the "second-to-second" experience really fun, in fact absurdly fun for software use. However this new approach must be done with style and panache. Quips in modals must be funny not forced. Effects must be sexy not garish and obnoxious. The fine line will be walked and we will not fall off.
|
| 51 |
+
But as we walk it we ascend.
|
| 52 |
+
|
| 53 |
+
The unconventional philosophy with regards to style here is taking a page from video game creation. Apparently great care goes into making the second-to-second experience of games great, addicting, appealing, enjoyable. For instance, in a card game when you mouse over a card, ig might get bigger; its background might animate; a sound might play; particle effects may surround it; and when you click it, the places where you may play it also light up, new sounds play etc. Every action suggests the next action through effects, and everything feels cool to do. Ideally this software, though iti s for LLM dataset generation, would also be fun/cool to use. So we are adopting that approach.
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
-- How this runs --
|
| 57 |
+
|
| 58 |
+
This will be run on a user's local machine and connect to a local Augmentoolkit server.
|
| 59 |
+
The tech stack should be optimized for smooth, stylish operation and **easy setup** without catastrophic dependency bloat.
|
| 60 |
+
Web or app based, either way is fine
|
| 61 |
+
|
| 62 |
+
Notably, any AI agent taking on development of this will lack a lot of context on what Augmentoolkit is and does. For most explanatory strings you will want to stub them or leave todos. We'll have to collaborate w.r.t. what information is important to show and how, and what the workflows are, though much of this is either revealed via the api structure or the description here.
|
| 63 |
+
|
| 64 |
+
Flexible design is key, and being able to change things without messing up the entire project is important. We want flexibility in UI updates, in state updates -- fragile programming is a big nono since this is being hacked together and hacked on. We want the AK47 of tech stack choice, design, and approach. It should work even if I drag it hrough the figurative mud. The API it is using is pretty simple after all.
|
| 65 |
+
|
| 66 |
+
### TECH STACK!!!
|
| 67 |
+
|
| 68 |
+
The local interface for the Augmentoolkit server will be a **web-based application** (specifically a Single Page Application, or SPA) built with the following technologies:
|
| 69 |
+
|
| 70 |
+
- **Type**: Web-based application (SPA)
|
| 71 |
+
- **Framework**: React with Vite
|
| 72 |
+
- **State Management**: Context API
|
| 73 |
+
- **Styling**: Tailwind CSS
|
| 74 |
+
- **Animations**: Framer Motion
|
| 75 |
+
- **Config Editing**: Monaco Editor
|
| 76 |
+
- **File Management**: Custom components with `react arborist` for performance, customization, and modernity.
|
| 77 |
+
- **API Communication**: Fetch API
|
| 78 |
+
- **Real-Time Updates**: Polling with `setInterval`
|
| 79 |
+
- **Development Tools**: ESLint, Prettier, Jest, React Testing Library
|
| 80 |
+
|
| 81 |
+
#### Thought Process Behind Tech Stack Decisions
|
| 82 |
+
|
| 83 |
+
1. **Web-Based Application (SPA)**
|
| 84 |
+
- **Why**: A web-based interface simplifies deployment and user setup. Users can run the Augmentoolkit server and access the interface through a browser, eliminating the need for platform-specific builds or complex installations. This aligns with the goal of making setup easy and accessible.
|
| 85 |
+
- **Reasoning**: Leveraging HTML, CSS, and JavaScript ensures cross-platform compatibility and rapid development, as these are widely supported and familiar technologies.
|
| 86 |
+
|
| 87 |
+
2. **React with Vite**
|
| 88 |
+
- **Why**: React’s component-based architecture is perfect for building a modular SPA with distinct pages like Configs, Inputs, Outputs, and Pipeline Execution. Vite enhances this by offering faster builds and a modern development experience compared to alternatives like Create React App.
|
| 89 |
+
- **Reasoning**: React’s ecosystem and Vite’s performance make this combination ideal for quick iteration and scalability.
|
| 90 |
+
|
| 91 |
+
3. **Context API for State Management**
|
| 92 |
+
- **Why**: The Context API, built into React, provides a lightweight solution for managing state (e.g., task IDs, config data) across components. It avoids the complexity of heavier tools like Redux for this project’s scope.
|
| 93 |
+
- **Reasoning**: Simplicity and integration with React make it a practical choice for a focused interface.
|
| 94 |
+
|
| 95 |
+
4. **Tailwind CSS for Styling**
|
| 96 |
+
- **Why**: Tailwind CSS, a utility-first framework, replaces Styled Components in this stack. It’s elegant, concise, and familiar to you, offering rapid styling with extensive customization options. Unlike Styled Components, which you’re less comfortable with, Tailwind’s utility classes allow you to style directly in your markup, streamlining development.
|
| 97 |
+
- **Reasoning**: Tailwind supports quick iteration and consistency while enabling creative UI designs (e.g., gradients, dark mode, tactile feedback), aligning with your preference and the goal of an engaging, game-like interface.
|
| 98 |
+
|
| 99 |
+
5. **Framer Motion for Animations**
|
| 100 |
+
- **Why**: Framer Motion integrates seamlessly with React and simplifies the creation of sophisticated animations (e.g., moving beads of light, particle effects) to make the interface fun and dynamic.
|
| 101 |
+
- **Reasoning**: Its ease of use and power enhance the user experience without adding unnecessary complexity.
|
| 102 |
+
|
| 103 |
+
6. **Monaco Editor for Config Editing**
|
| 104 |
+
- **Why**: The Monaco Editor (the engine behind VS Code) offers a robust, feature-rich environment for editing YAML configs, complete with syntax highlighting and validation to catch errors like invalid paths.
|
| 105 |
+
- **Reasoning**: It’s a proven tool that elevates the config-editing experience for users.
|
| 106 |
+
|
| 107 |
+
7. **Custom Components with `react-folder-tree` for File Management**
|
| 108 |
+
- **Why**: This lightweight library enables a clear, interactive directory structure for the Inputs and Outputs pages, supporting actions like browsing, uploading, downloading, and deleting files.
|
| 109 |
+
- **Reasoning**: It’s simple yet effective for file-related functionality without overcomplicating the stack.
|
| 110 |
+
|
| 111 |
+
8. **Fetch API for API Communication**
|
| 112 |
+
- **Why**: The Fetch API, built into browsers, is straightforward and sufficient for REST API interactions, avoiding the need for external libraries like Axios.
|
| 113 |
+
- **Reasoning**: It’s lightweight and meets the project’s communication needs efficiently.
|
| 114 |
+
|
| 115 |
+
9. **Polling with `setInterval` for Real-Time Updates**
|
| 116 |
+
- **Why**: Polling with `setInterval` provides a simple way to fetch task status, progress, and logs in real time. Intervals can be adjusted dynamically based on task activity for optimal performance.
|
| 117 |
+
- **Reasoning**: It’s easy to implement and effective for this use case, balancing simplicity and responsiveness.
|
| 118 |
+
|
| 119 |
+
10. **Development Tools**
|
| 120 |
+
- **Why**: ESLint and Prettier enforce code quality and consistent formatting, while Jest and React Testing Library enable unit and integration testing for reliability.
|
| 121 |
+
- **Reasoning**: These tools ensure maintainability and robustness, supporting a smooth development process.
|
| 122 |
+
|
| 123 |
+
This tech stack strikes a balance between **performance**, **ease of development**, and **flexibility**. It leverages Tailwind CSS for elegant, efficient styling per your preference, while delivering a functional, engaging interface that’s quick to set up and maintain.
|
| 124 |
+
|
| 125 |
+
**Start the development server**:
|
| 126 |
+
```bash
|
| 127 |
+
npm run dev
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
Note to self -- when you run at start the top tabs will react based on common flows. Usually you'll go to configs first, to run something. So, that one will be reacting. Pipeline monitor may be more demure than usually.
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
So beyond pipeline monitoring, we have a config revision to do (easyish), and also log management (easy)
|
atk-interface/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
atk-interface/public/vite.svg
ADDED
|
|
atk-interface/src/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
atk-interface/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
atk-interface/src/App.jsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Routes, Route } from 'react-router-dom';
|
| 2 |
+
import { TaskHistoryProvider } from './context/TaskHistoryContext';
|
| 3 |
+
import { FileManagerProvider } from './context/FileManagerContext';
|
| 4 |
+
import Layout from './components/Layout';
|
| 5 |
+
import HomePage from './pages/HomePage';
|
| 6 |
+
import ConfigSelector from './pages/ConfigSelector';
|
| 7 |
+
import ConfigEditor from './pages/ConfigEditor';
|
| 8 |
+
import InputManager from './pages/InputManager';
|
| 9 |
+
import OutputManager from './pages/OutputManager';
|
| 10 |
+
import PipelineMonitor from './pages/PipelineMonitor';
|
| 11 |
+
import NotFound from './pages/NotFound';
|
| 12 |
+
import LogManager from './pages/LogManager';
|
| 13 |
+
import LogViewer from './pages/LogViewer';
|
| 14 |
+
import Chat from './pages/Chat';
|
| 15 |
+
// import './App.css'; // Keep or remove based on whether it has useful base styles
|
| 16 |
+
|
| 17 |
+
function App() {
|
| 18 |
+
return (
|
| 19 |
+
<TaskHistoryProvider>
|
| 20 |
+
<FileManagerProvider>
|
| 21 |
+
<Routes>
|
| 22 |
+
<Route path="/" element={<Layout />}>
|
| 23 |
+
<Route index element={<HomePage />} /> {/* Default route is now HomePage */}
|
| 24 |
+
<Route path="configs" element={<ConfigSelector />} /> {/* Route for selecting configs */}
|
| 25 |
+
<Route path="configs/:configId" element={<ConfigEditor />} /> {/* Route for editing a specific config */}
|
| 26 |
+
<Route path="inputs" element={<InputManager />} />
|
| 27 |
+
<Route path="outputs" element={<OutputManager />} />
|
| 28 |
+
<Route path="monitor" element={<PipelineMonitor />} />
|
| 29 |
+
<Route path="logs" element={<LogManager />} />
|
| 30 |
+
<Route path="logs/:taskId" element={<LogViewer />} />
|
| 31 |
+
<Route path="chat" element={<Chat />} />
|
| 32 |
+
<Route path="*" element={<NotFound />} /> {/* Catch-all for 404 */}
|
| 33 |
+
</Route>
|
| 34 |
+
</Routes>
|
| 35 |
+
</FileManagerProvider>
|
| 36 |
+
</TaskHistoryProvider>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export default App;
|
atk-interface/src/api.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { handleApiError, getApiUrl } from './utils/apiUtils';
|
| 2 |
+
|
| 3 |
+
const API_BASE_URL = window.location.origin; // Use current origin for deployment
|
| 4 |
+
|
| 5 |
+
// Helper to handle common fetch logic and errors
|
| 6 |
+
async function handleApiResponse(response) {
|
| 7 |
+
if (!response.ok) {
|
| 8 |
+
let errorMsg = `API Error: ${response.status} ${response.statusText}`;
|
| 9 |
+
let errorData = null;
|
| 10 |
+
try {
|
| 11 |
+
// Attempt to parse error detail from backend
|
| 12 |
+
errorData = await response.json();
|
| 13 |
+
errorMsg = errorData.detail || JSON.stringify(errorData) || errorMsg;
|
| 14 |
+
} catch (e) {
|
| 15 |
+
// If parsing fails, try reading as text
|
| 16 |
+
try {
|
| 17 |
+
errorMsg = await response.text() || errorMsg;
|
| 18 |
+
} catch (textError) { /* Ignore text reading error */ }
|
| 19 |
+
}
|
| 20 |
+
console.error('API Error Data:', errorData);
|
| 21 |
+
const error = new Error(errorMsg);
|
| 22 |
+
error.status = response.status;
|
| 23 |
+
error.data = errorData;
|
| 24 |
+
throw error;
|
| 25 |
+
}
|
| 26 |
+
// If response has content, parse as JSON, otherwise return the response object
|
| 27 |
+
const contentType = response.headers.get("content-type");
|
| 28 |
+
if (contentType && contentType.indexOf("application/json") !== -1) {
|
| 29 |
+
return response.json();
|
| 30 |
+
}
|
| 31 |
+
if (contentType && contentType.indexOf("text/") !== -1) {
|
| 32 |
+
return response.text(); // Handle text responses like config content
|
| 33 |
+
}
|
| 34 |
+
// For downloads or other types, return the raw response
|
| 35 |
+
return response;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// --- Config API ---
|
| 39 |
+
|
| 40 |
+
export async function fetchConfigStructure(relativePath = '') {
|
| 41 |
+
const path = relativePath ? `/${encodeURIComponent(relativePath)}` : '.';
|
| 42 |
+
const response = await fetch(`${API_BASE_URL}/configs/structure/${path}`);
|
| 43 |
+
return handleApiResponse(response);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export async function fetchConfigContent(relativePath) {
|
| 47 |
+
if (!relativePath) throw new Error('Relative path is required for fetchConfigContent');
|
| 48 |
+
const response = await fetch(`${API_BASE_URL}/configs/content/${encodeURIComponent(relativePath)}`);
|
| 49 |
+
return handleApiResponse(response);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export async function saveConfigContent(relativePath, content) {
|
| 53 |
+
if (!relativePath) throw new Error('Relative path is required for saveConfigContent');
|
| 54 |
+
const response = await fetch(`${API_BASE_URL}/configs/content/${encodeURIComponent(relativePath)}`, {
|
| 55 |
+
method: 'POST',
|
| 56 |
+
headers: { 'Content-Type': 'text/plain' },
|
| 57 |
+
body: content,
|
| 58 |
+
});
|
| 59 |
+
return handleApiResponse(response);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Creates a new directory within the configs folder.
|
| 64 |
+
* @param {string} relativePath - The relative path for the new directory.
|
| 65 |
+
* @returns {Promise<object>} - API response.
|
| 66 |
+
*/
|
| 67 |
+
export const createConfigDirectory = async (relativePath) => {
|
| 68 |
+
const url = getApiUrl('/configs/directory');
|
| 69 |
+
const response = await fetch(url, {
|
| 70 |
+
method: 'POST',
|
| 71 |
+
headers: { 'Content-Type': 'application/json' },
|
| 72 |
+
body: JSON.stringify({ relative_path: relativePath }),
|
| 73 |
+
});
|
| 74 |
+
// Use handleApiResponse which includes error handling
|
| 75 |
+
return handleApiResponse(response);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Deletes a specific config file.
|
| 80 |
+
* @param {string} relativePath - The relative path of the file to delete.
|
| 81 |
+
* @returns {Promise<object>} - API response.
|
| 82 |
+
*/
|
| 83 |
+
export const deleteConfigItem = async (relativePath) => {
|
| 84 |
+
const url = getApiUrl(`/configs/${encodeURIComponent(relativePath)}`);
|
| 85 |
+
const response = await fetch(url, { method: 'DELETE' });
|
| 86 |
+
// Use handleApiResponse which includes error handling
|
| 87 |
+
return handleApiResponse(response);
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Moves/Renames a file or folder within the configs directory.
|
| 92 |
+
* @param {string} sourceRelativePath - Current relative path.
|
| 93 |
+
* @param {string} destinationRelativePath - New relative path.
|
| 94 |
+
* @returns {Promise<object>} - API response.
|
| 95 |
+
*/
|
| 96 |
+
export const moveConfigItem = async (sourceRelativePath, destinationRelativePath) => {
|
| 97 |
+
const url = getApiUrl('/configs/move');
|
| 98 |
+
const response = await fetch(url, {
|
| 99 |
+
method: 'POST',
|
| 100 |
+
headers: { 'Content-Type': 'application/json' },
|
| 101 |
+
body: JSON.stringify({
|
| 102 |
+
source_relative_path: sourceRelativePath,
|
| 103 |
+
destination_relative_path: destinationRelativePath,
|
| 104 |
+
}),
|
| 105 |
+
});
|
| 106 |
+
// Use handleApiResponse which includes error handling
|
| 107 |
+
return handleApiResponse(response);
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Duplicates a config file from a source alias to a destination path.
|
| 112 |
+
* @param {string} sourceAlias - The alias of the config file to duplicate.
|
| 113 |
+
* @param {string} destinationRelativePath - The destination path for the new file.
|
| 114 |
+
* @returns {Promise<object>} - API response.
|
| 115 |
+
*/
|
| 116 |
+
export const duplicateConfig = async (sourceAlias, destinationRelativePath) => {
|
| 117 |
+
const url = getApiUrl('/configs/duplicate');
|
| 118 |
+
const response = await fetch(url, {
|
| 119 |
+
method: 'POST',
|
| 120 |
+
headers: { 'Content-Type': 'application/json' },
|
| 121 |
+
body: JSON.stringify({
|
| 122 |
+
source_alias: sourceAlias,
|
| 123 |
+
destination_relative_path: destinationRelativePath,
|
| 124 |
+
}),
|
| 125 |
+
});
|
| 126 |
+
// Use handleApiResponse which includes error handling
|
| 127 |
+
return handleApiResponse(response);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Fetches available config aliases (e.g., from super_config.yaml) that can be used as sources for duplication.
|
| 132 |
+
* NOTE: Assumes a backend endpoint GET /configs/aliases exists.
|
| 133 |
+
* @returns {Promise<string[]>} - A list of config alias strings.
|
| 134 |
+
*/
|
| 135 |
+
export const fetchAvailableConfigAliases = async () => {
|
| 136 |
+
// TODO: Verify/Implement the corresponding backend endpoint GET /configs/aliases
|
| 137 |
+
const url = getApiUrl('/configs/aliases'); // Assuming this endpoint exists
|
| 138 |
+
const response = await fetch(url);
|
| 139 |
+
// Use handleApiResponse which includes error handling
|
| 140 |
+
return handleApiResponse(response);
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// --- Pipeline API ---
|
| 144 |
+
|
| 145 |
+
export const fetchAvailablePipelines = async () => {
|
| 146 |
+
const response = await fetch(getApiUrl('/pipelines/available'));
|
| 147 |
+
if (!response.ok) throw new Error('Failed to fetch available pipelines');
|
| 148 |
+
return response.json();
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
export async function runPipeline(nodePath, parameters) {
|
| 152 |
+
const response = await fetch(`${API_BASE_URL}/pipelines/run`, {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
headers: { 'Content-Type': 'application/json' },
|
| 155 |
+
body: JSON.stringify({ node_path: nodePath, parameters: parameters }),
|
| 156 |
+
});
|
| 157 |
+
return handleApiResponse(response);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// --- Input API ---
|
| 161 |
+
|
| 162 |
+
export const fetchInputStructure = async (relativePath = '.') => {
|
| 163 |
+
const url = getApiUrl(`/inputs/structure/${encodeURIComponent(relativePath)}`);
|
| 164 |
+
const response = await fetch(url);
|
| 165 |
+
if (!response.ok) {
|
| 166 |
+
await handleApiError(response, 'fetch input structure');
|
| 167 |
+
}
|
| 168 |
+
return response.json();
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
export const uploadInputFiles = async (relativePath, files) => {
|
| 172 |
+
const url = getApiUrl(`/inputs/upload/${encodeURIComponent(relativePath)}`);
|
| 173 |
+
const formData = new FormData();
|
| 174 |
+
files.forEach(file => formData.append('files', file, file.name)); // Use 'files' as key
|
| 175 |
+
|
| 176 |
+
const response = await fetch(url, {
|
| 177 |
+
method: 'POST',
|
| 178 |
+
body: formData,
|
| 179 |
+
// No 'Content-Type' header, browser sets it with boundary for multipart/form-data
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
if (!response.ok && response.status !== 207) { // 207 Multi-Status might indicate partial success
|
| 183 |
+
await handleApiError(response, 'upload input files');
|
| 184 |
+
}
|
| 185 |
+
// Even on 207, parse the body for details
|
| 186 |
+
const result = await response.json();
|
| 187 |
+
if (response.status === 207 && result.errors?.length > 0) {
|
| 188 |
+
// Optionally throw a more specific error or handle partial failures
|
| 189 |
+
console.warn('Partial upload failure:', result.errors);
|
| 190 |
+
// Example: throw new Error(`Upload completed with errors: ${result.errors.join(', ')}`);
|
| 191 |
+
}
|
| 192 |
+
if (!response.ok && response.status !== 207) {
|
| 193 |
+
// Throw if it wasn't OK and wasn't a 207
|
| 194 |
+
throw new Error(result.message || 'Upload failed');
|
| 195 |
+
}
|
| 196 |
+
return result;
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
export const downloadInputItem = async (relativePath) => {
|
| 200 |
+
const url = getApiUrl(`/inputs/download/${encodeURIComponent(relativePath)}`);
|
| 201 |
+
const response = await fetch(url);
|
| 202 |
+
if (!response.ok) {
|
| 203 |
+
await handleApiError(response, `download input item: ${relativePath}`);
|
| 204 |
+
}
|
| 205 |
+
// Don't parse JSON, return the raw response for blob processing
|
| 206 |
+
return response;
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
export const deleteInputItem = async (relativePath) => {
|
| 210 |
+
const url = getApiUrl(`/inputs/${encodeURIComponent(relativePath)}`);
|
| 211 |
+
const response = await fetch(url, { method: 'DELETE' });
|
| 212 |
+
if (!response.ok) {
|
| 213 |
+
await handleApiError(response, `delete input item: ${relativePath}`);
|
| 214 |
+
}
|
| 215 |
+
return response.json(); // Or just check status if no body expected on success
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
export const createInputDirectory = async (relativePath) => {
|
| 219 |
+
const url = getApiUrl('/inputs/directory');
|
| 220 |
+
const response = await fetch(url, {
|
| 221 |
+
method: 'POST',
|
| 222 |
+
headers: { 'Content-Type': 'application/json' },
|
| 223 |
+
body: JSON.stringify({ relative_path: relativePath }),
|
| 224 |
+
});
|
| 225 |
+
if (!response.ok) {
|
| 226 |
+
await handleApiError(response, `create input directory: ${relativePath}`);
|
| 227 |
+
}
|
| 228 |
+
return response.json();
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
/**
|
| 232 |
+
* Moves a file or folder within the inputs directory.
|
| 233 |
+
* @param {string} sourceRelativePath - The current relative path of the item to move.
|
| 234 |
+
* @param {string} destinationRelativePath - The new relative path for the item.
|
| 235 |
+
* @returns {Promise<object>} - The response object from the API.
|
| 236 |
+
*/
|
| 237 |
+
export const moveInputItem = async (sourceRelativePath, destinationRelativePath) => {
|
| 238 |
+
const url = getApiUrl('/inputs/move');
|
| 239 |
+
const response = await fetch(url, {
|
| 240 |
+
method: 'POST',
|
| 241 |
+
headers: { 'Content-Type': 'application/json' },
|
| 242 |
+
body: JSON.stringify({
|
| 243 |
+
source_relative_path: sourceRelativePath,
|
| 244 |
+
destination_relative_path: destinationRelativePath,
|
| 245 |
+
}),
|
| 246 |
+
});
|
| 247 |
+
if (!response.ok) {
|
| 248 |
+
await handleApiError(response, `move input item from ${sourceRelativePath} to ${destinationRelativePath}`);
|
| 249 |
+
}
|
| 250 |
+
return response.json();
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
// --- Output API ---
|
| 254 |
+
|
| 255 |
+
export const fetchOutputStructure = async (relativePath = '.') => {
|
| 256 |
+
const url = getApiUrl(`/outputs/structure/${encodeURIComponent(relativePath)}`);
|
| 257 |
+
const response = await fetch(url);
|
| 258 |
+
if (!response.ok) {
|
| 259 |
+
await handleApiError(response, 'fetch output structure');
|
| 260 |
+
}
|
| 261 |
+
return response.json();
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
export const downloadOutputItem = async (relativePath) => {
|
| 265 |
+
const url = getApiUrl(`/outputs/download/${encodeURIComponent(relativePath)}`);
|
| 266 |
+
const response = await fetch(url);
|
| 267 |
+
if (!response.ok) {
|
| 268 |
+
await handleApiError(response, `download output item: ${relativePath}`);
|
| 269 |
+
}
|
| 270 |
+
// Return raw response for blob processing
|
| 271 |
+
return response;
|
| 272 |
+
};
|
| 273 |
+
|
| 274 |
+
export const deleteOutputItem = async (relativePath) => {
|
| 275 |
+
const url = getApiUrl(`/outputs/${encodeURIComponent(relativePath)}`);
|
| 276 |
+
const response = await fetch(url, { method: 'DELETE' });
|
| 277 |
+
if (!response.ok) {
|
| 278 |
+
await handleApiError(response, `delete output item: ${relativePath}`);
|
| 279 |
+
}
|
| 280 |
+
return response.json(); // Or check status
|
| 281 |
+
};
|
| 282 |
+
|
| 283 |
+
export const moveOutputItem = async (sourceRelativePath, destinationRelativePath) => {
|
| 284 |
+
const url = getApiUrl('/outputs/move');
|
| 285 |
+
const response = await fetch(url, {
|
| 286 |
+
method: 'POST',
|
| 287 |
+
headers: { 'Content-Type': 'application/json' },
|
| 288 |
+
body: JSON.stringify({
|
| 289 |
+
source_relative_path: sourceRelativePath,
|
| 290 |
+
destination_relative_path: destinationRelativePath,
|
| 291 |
+
}),
|
| 292 |
+
});
|
| 293 |
+
if (!response.ok) {
|
| 294 |
+
await handleApiError(response, `move output item from ${sourceRelativePath} to ${destinationRelativePath}`);
|
| 295 |
+
}
|
| 296 |
+
return response.json();
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
// --- Task API ---
|
| 300 |
+
|
| 301 |
+
/**
|
| 302 |
+
* Fetches the status of a specific task.
|
| 303 |
+
* @param {string} taskId - The ID of the task.
|
| 304 |
+
* @returns {Promise<object>} - Task status object { task_id, status, message?, progress?, details? }.
|
| 305 |
+
*/
|
| 306 |
+
export const fetchTaskStatus = async (taskId) => {
|
| 307 |
+
if (!taskId) throw new Error('Task ID is required for fetchTaskStatus');
|
| 308 |
+
const url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/status`);
|
| 309 |
+
const response = await fetch(url);
|
| 310 |
+
if (!response.ok) {
|
| 311 |
+
await handleApiError(response, `fetch task status for ${taskId}`);
|
| 312 |
+
}
|
| 313 |
+
return response.json();
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
/**
|
| 317 |
+
* Fetches the parameters used for a specific task run.
|
| 318 |
+
* @param {string} taskId - The ID of the task.
|
| 319 |
+
* @returns {Promise<object>} - Object containing { task_id, parameters }.
|
| 320 |
+
*/
|
| 321 |
+
export const fetchTaskParameters = async (taskId) => {
|
| 322 |
+
if (!taskId) throw new Error('Task ID is required for fetchTaskParameters');
|
| 323 |
+
const url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/parameters`);
|
| 324 |
+
const response = await fetch(url);
|
| 325 |
+
// Handle 404 specifically for parameters, as it might just mean they weren't stored/expired
|
| 326 |
+
if (response.status === 404) {
|
| 327 |
+
console.warn(`Parameters not found for task ${taskId}. They might not have been stored or may have expired.`);
|
| 328 |
+
return { task_id: taskId, parameters: null }; // Return null for parameters
|
| 329 |
+
}
|
| 330 |
+
if (!response.ok) {
|
| 331 |
+
await handleApiError(response, `fetch task parameters for ${taskId}`);
|
| 332 |
+
}
|
| 333 |
+
return response.json();
|
| 334 |
+
};
|
| 335 |
+
|
| 336 |
+
/**
|
| 337 |
+
* Interrupts a running task or revokes a pending one.
|
| 338 |
+
* @param {string} taskId - The ID of the task.
|
| 339 |
+
* @returns {Promise<object>} - API response message.
|
| 340 |
+
*/
|
| 341 |
+
export const interruptTask = async (taskId) => {
|
| 342 |
+
if (!taskId) throw new Error('Task ID is required for interruptTask');
|
| 343 |
+
const url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/interrupt`);
|
| 344 |
+
const response = await fetch(url, { method: 'POST' });
|
| 345 |
+
if (!response.ok) {
|
| 346 |
+
await handleApiError(response, `interrupt task ${taskId}`);
|
| 347 |
+
}
|
| 348 |
+
return response.json();
|
| 349 |
+
};
|
| 350 |
+
|
| 351 |
+
/**
|
| 352 |
+
* Fetches the list of pending and scheduled tasks.
|
| 353 |
+
* @returns {Promise<object>} - Object containing { pending_tasks, scheduled_tasks, message }.
|
| 354 |
+
*/
|
| 355 |
+
export const fetchTaskQueue = async () => {
|
| 356 |
+
const url = getApiUrl('/tasks/queue');
|
| 357 |
+
const response = await fetch(url);
|
| 358 |
+
if (!response.ok) {
|
| 359 |
+
await handleApiError(response, 'fetch task queue');
|
| 360 |
+
}
|
| 361 |
+
return response.json();
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* Fetches the logs for a specific task.
|
| 366 |
+
* @param {string} taskId - The ID of the task.
|
| 367 |
+
* @param {number} [tail] - Optional number of lines to tail.
|
| 368 |
+
* @returns {Promise<object>} - Object containing { task_id, message, logs }.
|
| 369 |
+
*/
|
| 370 |
+
export const fetchTaskLogs = async (taskId, tail) => {
|
| 371 |
+
if (!taskId) throw new Error('Task ID is required for fetchTaskLogs');
|
| 372 |
+
let url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/logs`);
|
| 373 |
+
if (tail !== undefined && tail !== null) {
|
| 374 |
+
url += `?tail=${tail}`;
|
| 375 |
+
}
|
| 376 |
+
const response = await fetch(url);
|
| 377 |
+
// Handle 404 gracefully, log file might not exist yet
|
| 378 |
+
if (response.status === 404) {
|
| 379 |
+
console.log(`Log file for task ${taskId} not found.`);
|
| 380 |
+
return { task_id: taskId, message: "Log file not found.", logs: "" };
|
| 381 |
+
}
|
| 382 |
+
if (!response.ok) {
|
| 383 |
+
await handleApiError(response, `fetch task logs for ${taskId}`);
|
| 384 |
+
}
|
| 385 |
+
return response.json();
|
| 386 |
+
};
|
| 387 |
+
|
| 388 |
+
/**
|
| 389 |
+
* Deletes the log file for a specific task.
|
| 390 |
+
* @param {string} taskId - The ID of the task.
|
| 391 |
+
* @returns {Promise<object>} - API response message.
|
| 392 |
+
*/
|
| 393 |
+
export const deleteTaskLogs = async (taskId) => {
|
| 394 |
+
if (!taskId) throw new Error('Task ID is required for deleteTaskLogs');
|
| 395 |
+
const url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/logs`);
|
| 396 |
+
const response = await fetch(url, { method: 'DELETE' });
|
| 397 |
+
if (!response.ok) {
|
| 398 |
+
await handleApiError(response, `delete task logs for ${taskId}`);
|
| 399 |
+
}
|
| 400 |
+
return response.json();
|
| 401 |
+
};
|
| 402 |
+
|
| 403 |
+
/**
|
| 404 |
+
* Downloads the output directory for a specific task as a zip file.
|
| 405 |
+
* @param {string} taskId - The ID of the task.
|
| 406 |
+
* @returns {Promise<Response>} - The raw fetch Response object for blob processing.
|
| 407 |
+
*/
|
| 408 |
+
export const downloadTaskOutput = async (taskId) => {
|
| 409 |
+
if (!taskId) throw new Error('Task ID is required for downloadTaskOutput');
|
| 410 |
+
const url = getApiUrl(`/tasks/${encodeURIComponent(taskId)}/outputs/download`);
|
| 411 |
+
const response = await fetch(url);
|
| 412 |
+
if (!response.ok) {
|
| 413 |
+
// Handle 404 specifically - output might not exist
|
| 414 |
+
if (response.status === 404) {
|
| 415 |
+
throw new Error(`Output directory not found for task ${taskId}.`);
|
| 416 |
+
}
|
| 417 |
+
await handleApiError(response, `download task output for ${taskId}`);
|
| 418 |
+
}
|
| 419 |
+
// Return raw response for blob processing
|
| 420 |
+
return response;
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
/**
|
| 424 |
+
* Fetches the list of available log files.
|
| 425 |
+
* GET /logs
|
| 426 |
+
* @returns {Promise<string[]>} A promise that resolves to an array of log filenames.
|
| 427 |
+
*/
|
| 428 |
+
export const fetchLogFiles = async () => {
|
| 429 |
+
const response = await fetchWithErrorHandling(`${API_BASE_URL}/logs`);
|
| 430 |
+
const data = await response.json();
|
| 431 |
+
// Ensure we return an array, even if the API response structure is slightly off
|
| 432 |
+
return Array.isArray(data?.log_files) ? data.log_files : [];
|
| 433 |
+
};
|
| 434 |
+
|
| 435 |
+
/**
|
| 436 |
+
* Deletes a specific log file based on its task ID.
|
| 437 |
+
* DELETE /tasks/{task_id}/logs
|
| 438 |
+
* @param {string} taskId - The task ID (extracted from the log filename).
|
| 439 |
+
* @returns {Promise<object>} A promise that resolves to the API response message.
|
| 440 |
+
*/
|
| 441 |
+
export const deleteLogFile = async (taskId) => {
|
| 442 |
+
if (!taskId) {
|
| 443 |
+
throw new Error("Task ID is required to delete a log file.");
|
| 444 |
+
}
|
| 445 |
+
// Basic check to prevent deleting files without UUID-like format, adjust if task IDs differ
|
| 446 |
+
// if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(taskId)) {
|
| 447 |
+
// console.warn(`Attempting to delete log for potentially invalid Task ID: ${taskId}`);
|
| 448 |
+
// }
|
| 449 |
+
const response = await fetchWithErrorHandling(`${API_BASE_URL}/tasks/${encodeURIComponent(taskId)}/logs`, {
|
| 450 |
+
method: 'DELETE',
|
| 451 |
+
});
|
| 452 |
+
return response.json();
|
| 453 |
+
};
|
| 454 |
+
|
| 455 |
+
/**
|
| 456 |
+
* Clears all log files in the logs directory.
|
| 457 |
+
* DELETE /logs
|
| 458 |
+
* @returns {Promise<object>} A promise that resolves to the API response (message and potential errors).
|
| 459 |
+
*/
|
| 460 |
+
export const clearAllLogs = async () => {
|
| 461 |
+
const response = await fetchWithErrorHandling(`${API_BASE_URL}/logs`, {
|
| 462 |
+
method: 'DELETE',
|
| 463 |
+
});
|
| 464 |
+
return response.json(); // Example: {"message": "Successfully deleted X log file(s)...", "errors": []}
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
// Utility function assumed to exist from other files (InputManager/ConfigSelector context)
|
| 468 |
+
// If it doesn't exist, it needs to be created.
|
| 469 |
+
async function fetchWithErrorHandling(url, options = {}) {
|
| 470 |
+
const response = await fetch(url, options);
|
| 471 |
+
if (!response.ok) {
|
| 472 |
+
let errorPayload;
|
| 473 |
+
try {
|
| 474 |
+
errorPayload = await response.json(); // Try to parse JSON error detail
|
| 475 |
+
} catch (e) {
|
| 476 |
+
// Ignore parsing error, use status text
|
| 477 |
+
}
|
| 478 |
+
const errorMessage = errorPayload?.detail || response.statusText || `Request failed with status ${response.status}`;
|
| 479 |
+
console.error(`API Error (${response.status}) for ${options.method || 'GET'} ${url}:`, errorMessage, errorPayload);
|
| 480 |
+
// Throw an error that includes the status code and message
|
| 481 |
+
const error = new Error(errorMessage);
|
| 482 |
+
error.status = response.status;
|
| 483 |
+
error.payload = errorPayload;
|
| 484 |
+
throw error;
|
| 485 |
+
}
|
| 486 |
+
return response;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
// Utility to get the base API URL (if needed elsewhere, or for env vars)
|
| 490 |
+
// export const getBaseApiUrl = () => API_BASE_URL;
|
atk-interface/src/assets/atk-logo-notscuffed.png
ADDED
|
atk-interface/src/assets/react.svg
ADDED
|
|
atk-interface/src/components/ButtonParticles.jsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
const random = (min, max) => Math.random() * (max - min) + min;
|
| 5 |
+
|
| 6 |
+
const Particle = ({ id, color, size, duration }) => {
|
| 7 |
+
const angle = random(0, Math.PI * 2);
|
| 8 |
+
const radius = random(40, 85); // How far particles travel (Increased)
|
| 9 |
+
const endX = Math.cos(angle) * radius;
|
| 10 |
+
const endY = Math.sin(angle) * radius;
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<motion.div
|
| 14 |
+
className="absolute rounded-full pointer-events-none"
|
| 15 |
+
style={{
|
| 16 |
+
backgroundColor: color,
|
| 17 |
+
width: size,
|
| 18 |
+
height: size,
|
| 19 |
+
left: '50%', // Start at center
|
| 20 |
+
top: '50%',
|
| 21 |
+
translateX: '-50%', // Adjust for centering
|
| 22 |
+
translateY: '-50%',
|
| 23 |
+
}}
|
| 24 |
+
initial={{ opacity: 1, scale: 1 }}
|
| 25 |
+
animate={{
|
| 26 |
+
x: endX,
|
| 27 |
+
y: endY,
|
| 28 |
+
scale: 0, // Shrink to disappear
|
| 29 |
+
opacity: 0,
|
| 30 |
+
}}
|
| 31 |
+
transition={{ duration: duration, ease: 'easeOut' }}
|
| 32 |
+
/>
|
| 33 |
+
);
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const ButtonParticles = ({ trigger, color = '#ffffff', count = 8, size = 3.5, duration = 0.45 }) => {
|
| 37 |
+
const [particles, setParticles] = useState([]);
|
| 38 |
+
|
| 39 |
+
console.log(`ButtonParticles: Rendered. Current trigger=${trigger}, particles=${particles.length}`);
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
console.log(`ButtonParticles: useEffect triggered. trigger=${trigger}`);
|
| 43 |
+
if (trigger > 0) { // trigger increments on each click
|
| 44 |
+
console.log(`ButtonParticles: Trigger > 0 (${trigger}). Generating ${count} particles.`);
|
| 45 |
+
const newParticles = Array.from({ length: count }).map((_, i) => ({
|
| 46 |
+
id: `${trigger}-${i}`, // Unique key based on trigger and index
|
| 47 |
+
color,
|
| 48 |
+
size,
|
| 49 |
+
duration,
|
| 50 |
+
}));
|
| 51 |
+
setParticles(newParticles);
|
| 52 |
+
console.log("ButtonParticles: Particles state updated:", newParticles);
|
| 53 |
+
|
| 54 |
+
// Optional: Clear particles after animation - not strictly needed as they animate to opacity 0
|
| 55 |
+
const timer = setTimeout(() => {
|
| 56 |
+
console.log(`ButtonParticles: Clearing particles after ${duration}s timeout.`);
|
| 57 |
+
setParticles([]);
|
| 58 |
+
}, duration * 1000); // Clear particles after animation
|
| 59 |
+
return () => {
|
| 60 |
+
console.log("ButtonParticles: useEffect cleanup running.");
|
| 61 |
+
clearTimeout(timer)
|
| 62 |
+
};
|
| 63 |
+
}
|
| 64 |
+
}, [trigger, color, count, size, duration]);
|
| 65 |
+
|
| 66 |
+
// Render only the particles currently animating
|
| 67 |
+
return (
|
| 68 |
+
<>
|
| 69 |
+
{particles.map(p => (
|
| 70 |
+
<React.Fragment key={p.id}> {/* Use Fragment to add log without extra div */}
|
| 71 |
+
{/* {console.log(`ButtonParticles: Rendering Particle id=${p.id}`)} */}
|
| 72 |
+
<Particle key={p.id} {...p} />
|
| 73 |
+
</React.Fragment>
|
| 74 |
+
))}
|
| 75 |
+
</>
|
| 76 |
+
);
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
export default ButtonParticles;
|
atk-interface/src/components/DocumentStackBackground.jsx
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import { motion, useAnimation, AnimatePresence } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
// Helper function for random numbers
|
| 5 |
+
const random = (min, max) => Math.random() * (max - min) + min;
|
| 6 |
+
|
| 7 |
+
/* ────────────────────────────────────────────────────────────────────────────
|
| 8 |
+
Document: single paper in the stack
|
| 9 |
+
------------------------------------------------------------------------*/
|
| 10 |
+
const Document = ({
|
| 11 |
+
id,
|
| 12 |
+
yOffset,
|
| 13 |
+
zIndex,
|
| 14 |
+
onAnimationComplete,
|
| 15 |
+
onSortMoveComplete,
|
| 16 |
+
isTop,
|
| 17 |
+
isBeingRemoved,
|
| 18 |
+
isExploding,
|
| 19 |
+
isSortingMove,
|
| 20 |
+
targetYOffset,
|
| 21 |
+
dimensions,
|
| 22 |
+
}) => {
|
| 23 |
+
const controls = useAnimation();
|
| 24 |
+
|
| 25 |
+
// Random explosion vectors (memoised per mount)
|
| 26 |
+
const explodeX = useRef(random(-dimensions.width * 0.4, dimensions.width * 0.4)).current;
|
| 27 |
+
const explodeY = useRef(random(-dimensions.height * 0.4, dimensions.height * 0.4)).current;
|
| 28 |
+
const explodeRotate = useRef(random(-180, 180)).current;
|
| 29 |
+
|
| 30 |
+
/* ---------------------------------------------------------------------
|
| 31 |
+
Imperative animation orchestrator
|
| 32 |
+
---------------------------------------------------------------------*/
|
| 33 |
+
const animateDocument = useCallback(async () => {
|
| 34 |
+
if (isExploding) {
|
| 35 |
+
// Radial blast
|
| 36 |
+
await controls.start({
|
| 37 |
+
x: explodeX,
|
| 38 |
+
translateY: yOffset + explodeY,
|
| 39 |
+
rotate: explodeRotate,
|
| 40 |
+
opacity: 0,
|
| 41 |
+
transition: { duration: 1.5, ease: 'easeOut' },
|
| 42 |
+
});
|
| 43 |
+
} else if (isBeingRemoved && isTop) {
|
| 44 |
+
// Toss off-screen
|
| 45 |
+
await controls.start({
|
| 46 |
+
x: random(dimensions.width * 0.3, dimensions.width * 0.6) * (Math.random() > 0.5 ? 1 : -1),
|
| 47 |
+
translateY: -dimensions.height * 0.6,
|
| 48 |
+
rotate: random(-90, 90),
|
| 49 |
+
opacity: 0,
|
| 50 |
+
transition: { duration: 0.8, ease: 'easeOut' },
|
| 51 |
+
});
|
| 52 |
+
onAnimationComplete?.();
|
| 53 |
+
} else if (isSortingMove) {
|
| 54 |
+
/* --- Three-stage sideways shuffle --- */
|
| 55 |
+
const sideOffset = 250;
|
| 56 |
+
const moveOutDuration = 0.3;
|
| 57 |
+
const moveDownDuration = 0.4;
|
| 58 |
+
const moveInDuration = 0.3;
|
| 59 |
+
|
| 60 |
+
// Bring to front instantly
|
| 61 |
+
await controls.start({ opacity: 1, x: 0, translateY: yOffset, rotate: 0, zIndex: zIndex + 10 }, { duration: 0 });
|
| 62 |
+
// 1. Sideways
|
| 63 |
+
await controls.start({ x: sideOffset, transition: { duration: moveOutDuration, ease: 'easeOut' } });
|
| 64 |
+
// 2. Downwards to slot
|
| 65 |
+
await controls.start({ translateY: targetYOffset, transition: { duration: moveDownDuration, ease: 'circIn' } });
|
| 66 |
+
// 3. Back in
|
| 67 |
+
await controls.start({ x: 0, transition: { duration: moveInDuration, ease: 'easeOut' } });
|
| 68 |
+
|
| 69 |
+
onSortMoveComplete?.(id);
|
| 70 |
+
} else {
|
| 71 |
+
// Idle re-stacking movement
|
| 72 |
+
await controls.start({
|
| 73 |
+
x: 0,
|
| 74 |
+
translateY: yOffset,
|
| 75 |
+
zIndex,
|
| 76 |
+
rotate: 0,
|
| 77 |
+
opacity: 1,
|
| 78 |
+
transition: { duration: 0.5, ease: 'easeOut' },
|
| 79 |
+
});
|
| 80 |
+
onAnimationComplete?.();
|
| 81 |
+
}
|
| 82 |
+
}, [controls, dimensions, explodeRotate, explodeX, explodeY, id, isBeingRemoved, isExploding, isSortingMove, isTop, targetYOffset, yOffset, zIndex, onAnimationComplete, onSortMoveComplete]);
|
| 83 |
+
|
| 84 |
+
/* ---------------------------------------------------------------------
|
| 85 |
+
Run animator only *after* the motion.div has mounted.
|
| 86 |
+
---------------------------------------------------------------------*/
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
const raf = requestAnimationFrame(animateDocument);
|
| 89 |
+
return () => cancelAnimationFrame(raf);
|
| 90 |
+
}, [animateDocument]);
|
| 91 |
+
|
| 92 |
+
/* ---------------------------------------------------------------------
|
| 93 |
+
Disable Framer‑Motion layout magic while this doc is under manual
|
| 94 |
+
control (sorting, toss, or explosion). This prevents FM from measuring
|
| 95 |
+
a fresh layout on every React re‑render – the culprit behind the
|
| 96 |
+
"teleport‑back‑to‑centre" behaviour when the parent component updates.
|
| 97 |
+
---------------------------------------------------------------------*/
|
| 98 |
+
const enableLayout = !isSortingMove && !isBeingRemoved && !isExploding;
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<motion.div
|
| 102 |
+
key={id}
|
| 103 |
+
{...(enableLayout ? { layout: 'position' } : {})}
|
| 104 |
+
initial={{ x: 0, translateY: yOffset, zIndex, opacity: 1, rotate: 0 }}
|
| 105 |
+
animate={controls}
|
| 106 |
+
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.3 } }}
|
| 107 |
+
className="absolute w-64 h-5 bg-gray-300 border border-gray-400 rounded-sm shadow-md origin-center"
|
| 108 |
+
/>
|
| 109 |
+
);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
/* ────────────────────────────────────────────────────────────────────────────
|
| 113 |
+
DocumentStackBackground: orchestrates the pile of papers
|
| 114 |
+
------------------------------------------------------------------------*/
|
| 115 |
+
const DocumentStackBackground = ({
|
| 116 |
+
triggerExplode = false,
|
| 117 |
+
triggerTossTop = false,
|
| 118 |
+
onExplodeComplete,
|
| 119 |
+
onTossComplete,
|
| 120 |
+
}) => {
|
| 121 |
+
const NUM_DOCUMENTS = 15;
|
| 122 |
+
const STACK_OFFSET_Y = 30;
|
| 123 |
+
|
| 124 |
+
const [documents, setDocuments] = useState([]);
|
| 125 |
+
const [isAnimating, setIsAnimating] = useState(false);
|
| 126 |
+
const [isExploded, setIsExploded] = useState(false);
|
| 127 |
+
|
| 128 |
+
const intervalRef = useRef(null);
|
| 129 |
+
const [movingDocInfo, setMovingDocInfo] = useState({ id: null, targetYOffset: 0 });
|
| 130 |
+
|
| 131 |
+
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
| 132 |
+
const containerRef = useRef(null);
|
| 133 |
+
|
| 134 |
+
/* -------------------------------------------------------------------
|
| 135 |
+
Helpers: size, (re)initialisation, periodic shuffle
|
| 136 |
+
-------------------------------------------------------------------*/
|
| 137 |
+
useEffect(() => {
|
| 138 |
+
const handleResize = () => {
|
| 139 |
+
if (containerRef.current) {
|
| 140 |
+
setDimensions({
|
| 141 |
+
width: containerRef.current.offsetWidth,
|
| 142 |
+
height: containerRef.current.offsetHeight,
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
handleResize();
|
| 147 |
+
window.addEventListener('resize', handleResize);
|
| 148 |
+
return () => window.removeEventListener('resize', handleResize);
|
| 149 |
+
}, []);
|
| 150 |
+
|
| 151 |
+
/* (re)build the stack */
|
| 152 |
+
const initializeDocuments = useCallback(() => {
|
| 153 |
+
if (dimensions.height === 0) return;
|
| 154 |
+
|
| 155 |
+
const initialDocs = Array.from({ length: NUM_DOCUMENTS }).map((_, i) => ({
|
| 156 |
+
id: `doc-${i}-${Date.now()}`,
|
| 157 |
+
yOffset: (i - NUM_DOCUMENTS / 2 + 0.5) * STACK_OFFSET_Y,
|
| 158 |
+
zIndex: i,
|
| 159 |
+
isBeingRemoved: false,
|
| 160 |
+
}));
|
| 161 |
+
|
| 162 |
+
setDocuments(initialDocs);
|
| 163 |
+
setMovingDocInfo({ id: null, targetYOffset: 0 });
|
| 164 |
+
setIsExploded(false);
|
| 165 |
+
setIsAnimating(false);
|
| 166 |
+
}, [NUM_DOCUMENTS, STACK_OFFSET_Y, dimensions.height]);
|
| 167 |
+
|
| 168 |
+
/* kick-off on mount */
|
| 169 |
+
useEffect(() => initializeDocuments(), [initializeDocuments]);
|
| 170 |
+
|
| 171 |
+
/* ---------------------------------------------------------------
|
| 172 |
+
Idle shuffling logic (unchanged)
|
| 173 |
+
----------------------------------------------------------------*/
|
| 174 |
+
const sortStep = useCallback(() => {
|
| 175 |
+
if (isAnimating || documents.length < 2 || isExploded || movingDocInfo.id) return;
|
| 176 |
+
|
| 177 |
+
const indexToRemove = Math.floor(random(1, documents.length - 1));
|
| 178 |
+
const docToMove = documents[indexToRemove];
|
| 179 |
+
if (!docToMove) return;
|
| 180 |
+
|
| 181 |
+
const targetYOffset = (documents.length - 1 - documents.length / 2 + 0.5) * STACK_OFFSET_Y;
|
| 182 |
+
|
| 183 |
+
setIsAnimating(true);
|
| 184 |
+
setMovingDocInfo({ id: docToMove.id, targetYOffset });
|
| 185 |
+
}, [STACK_OFFSET_Y, documents, isAnimating, isExploded, movingDocInfo.id]);
|
| 186 |
+
|
| 187 |
+
/* … interval helpers (unchanged) … */
|
| 188 |
+
const stopSortInterval = useCallback(() => {
|
| 189 |
+
if (intervalRef.current) {
|
| 190 |
+
clearInterval(intervalRef.current);
|
| 191 |
+
intervalRef.current = null;
|
| 192 |
+
}
|
| 193 |
+
}, []);
|
| 194 |
+
|
| 195 |
+
const startSortInterval = useCallback(() => {
|
| 196 |
+
stopSortInterval();
|
| 197 |
+
if (!isAnimating && !isExploded) {
|
| 198 |
+
intervalRef.current = setInterval(sortStep, 1000);
|
| 199 |
+
}
|
| 200 |
+
}, [sortStep, stopSortInterval, isAnimating, isExploded]);
|
| 201 |
+
|
| 202 |
+
/* start-stop idle shuffle based on explosion and animation state */
|
| 203 |
+
useEffect(() => {
|
| 204 |
+
if (!isExploded && !isAnimating) {
|
| 205 |
+
startSortInterval();
|
| 206 |
+
} else {
|
| 207 |
+
stopSortInterval();
|
| 208 |
+
}
|
| 209 |
+
return stopSortInterval;
|
| 210 |
+
}, [isExploded, isAnimating, startSortInterval, stopSortInterval]);
|
| 211 |
+
|
| 212 |
+
/* -----------------------------------------------------------------
|
| 213 |
+
Toss-top trigger
|
| 214 |
+
- Stop interval implicitly by setting isAnimating flag
|
| 215 |
+
- Remove the doc from state *after* animation completes via onAnimationComplete
|
| 216 |
+
- Interval restart is handled by the useEffect watching isAnimating
|
| 217 |
+
----------------------------------------------------------------*/
|
| 218 |
+
useEffect(() => {
|
| 219 |
+
if (triggerTossTop && documents.length > 0 && !isAnimating && !isExploded) {
|
| 220 |
+
setIsAnimating(true);
|
| 221 |
+
setDocuments((prev) => {
|
| 222 |
+
const next = [...prev];
|
| 223 |
+
if (next.length > 0) {
|
| 224 |
+
next[next.length - 1] = { ...next[next.length - 1], isBeingRemoved: true };
|
| 225 |
+
}
|
| 226 |
+
return next;
|
| 227 |
+
});
|
| 228 |
+
}
|
| 229 |
+
}, [triggerTossTop, documents.length, isAnimating, isExploded]);
|
| 230 |
+
|
| 231 |
+
/* -----------------------------------------------------------------
|
| 232 |
+
Explode trigger – *do not* immediately clear the list so that the
|
| 233 |
+
papers have something to animate!
|
| 234 |
+
----------------------------------------------------------------*/
|
| 235 |
+
useEffect(() => {
|
| 236 |
+
if (triggerExplode && !isExploded && !isAnimating) {
|
| 237 |
+
setIsAnimating(true);
|
| 238 |
+
setIsExploded(true);
|
| 239 |
+
setMovingDocInfo({ id: null, targetYOffset: 0 });
|
| 240 |
+
}
|
| 241 |
+
}, [triggerExplode, isExploded, isAnimating]);
|
| 242 |
+
|
| 243 |
+
/* once exploded, wait, then rebuild the stack */
|
| 244 |
+
useEffect(() => {
|
| 245 |
+
if (!isExploded) return;
|
| 246 |
+
|
| 247 |
+
const explosionDuration = 1500;
|
| 248 |
+
const reinitDelay = 1000;
|
| 249 |
+
const tid = setTimeout(() => {
|
| 250 |
+
initializeDocuments();
|
| 251 |
+
onExplodeComplete?.();
|
| 252 |
+
}, explosionDuration + reinitDelay);
|
| 253 |
+
|
| 254 |
+
return () => clearTimeout(tid);
|
| 255 |
+
}, [isExploded, initializeDocuments, onExplodeComplete]);
|
| 256 |
+
|
| 257 |
+
/* Callback for when *any* document's animation finishes */
|
| 258 |
+
const handleDocumentAnimationComplete = useCallback((docId, isRemoval = false) => {
|
| 259 |
+
if (isRemoval) {
|
| 260 |
+
setDocuments((prev) => prev.filter(doc => doc.id !== docId));
|
| 261 |
+
onTossComplete?.();
|
| 262 |
+
setIsAnimating(false);
|
| 263 |
+
}
|
| 264 |
+
}, [onTossComplete]);
|
| 265 |
+
|
| 266 |
+
/* -----------------------------------------------------------------*/
|
| 267 |
+
return (
|
| 268 |
+
<div
|
| 269 |
+
ref={containerRef}
|
| 270 |
+
data-id="document-stack-background"
|
| 271 |
+
className="absolute inset-0 overflow-hidden z-0 pointer-events-none bg-gradient-to-b from-gray-800 via-gray-900 to-black"
|
| 272 |
+
>
|
| 273 |
+
<AnimatePresence>
|
| 274 |
+
<div className="absolute inset-0 flex justify-center items-center">
|
| 275 |
+
{documents.map((doc, idx) => (
|
| 276 |
+
<Document
|
| 277 |
+
key={doc.id}
|
| 278 |
+
id={doc.id}
|
| 279 |
+
yOffset={doc.yOffset}
|
| 280 |
+
zIndex={doc.zIndex}
|
| 281 |
+
isTop={idx === documents.length - 1}
|
| 282 |
+
isBeingRemoved={doc.isBeingRemoved}
|
| 283 |
+
isExploding={isExploded}
|
| 284 |
+
isSortingMove={doc.id === movingDocInfo.id}
|
| 285 |
+
targetYOffset={doc.id === movingDocInfo.id ? movingDocInfo.targetYOffset : 0}
|
| 286 |
+
onAnimationComplete={() => handleDocumentAnimationComplete(doc.id, doc.isBeingRemoved)}
|
| 287 |
+
onSortMoveComplete={(id) => {
|
| 288 |
+
setDocuments((prev) => {
|
| 289 |
+
const i = prev.findIndex((d) => d.id === id);
|
| 290 |
+
if (i === -1) return prev;
|
| 291 |
+
const copy = [...prev];
|
| 292 |
+
const [moved] = copy.splice(i, 1);
|
| 293 |
+
copy.push(moved);
|
| 294 |
+
return copy.map((d, j) => ({
|
| 295 |
+
...d,
|
| 296 |
+
yOffset: (j - copy.length / 2 + 0.5) * STACK_OFFSET_Y,
|
| 297 |
+
zIndex: j,
|
| 298 |
+
}));
|
| 299 |
+
});
|
| 300 |
+
setMovingDocInfo({ id: null, targetYOffset: 0 });
|
| 301 |
+
setIsAnimating(false);
|
| 302 |
+
}}
|
| 303 |
+
dimensions={dimensions}
|
| 304 |
+
/>
|
| 305 |
+
))}
|
| 306 |
+
</div>
|
| 307 |
+
</AnimatePresence>
|
| 308 |
+
</div>
|
| 309 |
+
);
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
// Wrap the component with React.memo
|
| 313 |
+
const MemoizedDocumentStackBackground = React.memo(DocumentStackBackground);
|
| 314 |
+
|
| 315 |
+
export default MemoizedDocumentStackBackground;
|
atk-interface/src/components/EnhancedButton.jsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import { motion, useAnimation } from "framer-motion";
|
| 3 |
+
import ButtonParticles from "./ButtonParticles";
|
| 4 |
+
|
| 5 |
+
const EnhancedButton = ({
|
| 6 |
+
onClick,
|
| 7 |
+
children,
|
| 8 |
+
className = "",
|
| 9 |
+
disabled = false,
|
| 10 |
+
title = "",
|
| 11 |
+
particleColor = "#ffffff",
|
| 12 |
+
particleCount = 50,
|
| 13 |
+
particleSize = 10,
|
| 14 |
+
particleDuration = 3,
|
| 15 |
+
...props
|
| 16 |
+
}) => {
|
| 17 |
+
const [isHovering, setIsHovering] = useState(false);
|
| 18 |
+
const [hasBeenClickedSinceHover, setHasBeenClickedSinceHover] = useState(false);
|
| 19 |
+
const [particleTrigger, setParticleTrigger] = useState(0);
|
| 20 |
+
|
| 21 |
+
/* --- controller that drives ONLY the shake --- */
|
| 22 |
+
const shake = useAnimation();
|
| 23 |
+
|
| 24 |
+
const fireShake = () =>
|
| 25 |
+
shake.start({
|
| 26 |
+
x: [0, -3, 3, -3, 3, 0],
|
| 27 |
+
y: [0, 3, -3, 3, -3, 0],
|
| 28 |
+
transition: { duration: 0.25, ease: "easeInOut" }
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
const handleClick = (e) => {
|
| 32 |
+
if (disabled) return;
|
| 33 |
+
onClick?.(e);
|
| 34 |
+
setHasBeenClickedSinceHover(true);
|
| 35 |
+
setParticleTrigger((t) => t + 1);
|
| 36 |
+
fireShake();
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
/* -------- hover / normal variants (scale only) -------- */
|
| 40 |
+
const variants = {
|
| 41 |
+
initial: { scale: 1, filter: "brightness(1)" },
|
| 42 |
+
hoverInitial: {
|
| 43 |
+
scale: [1, 1.06, 1],
|
| 44 |
+
transition: { scale: { duration: 0.8, repeat: Infinity, ease: "easeInOut" } }
|
| 45 |
+
},
|
| 46 |
+
hoverClicked: { scale: 1.03, transition: { duration: 0.2 } }
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const state =
|
| 50 |
+
disabled
|
| 51 |
+
? "initial"
|
| 52 |
+
: isHovering
|
| 53 |
+
? hasBeenClickedSinceHover
|
| 54 |
+
? "hoverClicked"
|
| 55 |
+
: "hoverInitial"
|
| 56 |
+
: "initial";
|
| 57 |
+
|
| 58 |
+
/* ---------------- render ---------------- */
|
| 59 |
+
return (
|
| 60 |
+
/* wrapper receives the shake controller */
|
| 61 |
+
<motion.div animate={shake} initial={{ x: 0, y: 0 }}>
|
| 62 |
+
<motion.button
|
| 63 |
+
type="button"
|
| 64 |
+
className={`relative overflow-visible transition-colors duration-200 ease-in-out
|
| 65 |
+
${className} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
| 66 |
+
title={title}
|
| 67 |
+
disabled={disabled}
|
| 68 |
+
variants={variants}
|
| 69 |
+
initial="initial"
|
| 70 |
+
animate={state}
|
| 71 |
+
whileTap={!disabled ? { scale: 0.8 } : false} /* press-in squash only */
|
| 72 |
+
onHoverStart={() => { setIsHovering(true); setHasBeenClickedSinceHover(false); }}
|
| 73 |
+
onHoverEnd={() => setIsHovering(false)}
|
| 74 |
+
onClick={handleClick}
|
| 75 |
+
{...props}
|
| 76 |
+
>
|
| 77 |
+
<span className="relative z-10 flex items-center justify-center gap-2">
|
| 78 |
+
{children}
|
| 79 |
+
</span>
|
| 80 |
+
|
| 81 |
+
{/* particles */}
|
| 82 |
+
<div className="absolute inset-0 z-0 pointer-events-none">
|
| 83 |
+
<ButtonParticles
|
| 84 |
+
trigger={particleTrigger}
|
| 85 |
+
color={particleColor}
|
| 86 |
+
count={particleCount}
|
| 87 |
+
size={particleSize}
|
| 88 |
+
duration={particleDuration}
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
</motion.button>
|
| 92 |
+
</motion.div>
|
| 93 |
+
);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
export default EnhancedButton;
|
atk-interface/src/components/FireworksBackground.jsx
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, {
|
| 2 |
+
useState,
|
| 3 |
+
useEffect,
|
| 4 |
+
useRef,
|
| 5 |
+
useMemo,
|
| 6 |
+
useCallback,
|
| 7 |
+
} from 'react';
|
| 8 |
+
import { motion } from 'framer-motion';
|
| 9 |
+
|
| 10 |
+
/* ---------- helpers ---------- */
|
| 11 |
+
|
| 12 |
+
const random = (min, max) => Math.random() * (max - min) + min;
|
| 13 |
+
const uuid = () =>
|
| 14 |
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
| 15 |
+
? crypto.randomUUID()
|
| 16 |
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
| 17 |
+
|
| 18 |
+
// Define possible firework colors
|
| 19 |
+
const fireworkColors = [
|
| 20 |
+
// '#FF5733', // Orange-Red. NOTE we'll use a round of red fireworks for deletion and a round of bright green ones for downloading. Also we want the download/delete buttons to follow along the side of the page when scrolling far down due to a lot of items -- I Will make that change in a bit.
|
| 21 |
+
// '#FFC300', // Vivid Yellow
|
| 22 |
+
// '#DAF7A6', // Light Green
|
| 23 |
+
// '#A0E7E5', // Light Blue
|
| 24 |
+
// '#B4A0E7', // Lavender
|
| 25 |
+
// '#E7A0C4', // Pink
|
| 26 |
+
'#FFFFFF', // White
|
| 27 |
+
];
|
| 28 |
+
/* =================================================================== */
|
| 29 |
+
/* MAIN */
|
| 30 |
+
/* =================================================================== */
|
| 31 |
+
|
| 32 |
+
const FireworksBackground = ({
|
| 33 |
+
launchInterval = 5000,
|
| 34 |
+
triggerBurst = false,
|
| 35 |
+
burstColorOverride = null,
|
| 36 |
+
}) => {
|
| 37 |
+
/* ---------- size ---------- */
|
| 38 |
+
|
| 39 |
+
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
| 40 |
+
const containerRef = useRef(null);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
const handleResize = () => {
|
| 44 |
+
if (containerRef.current) {
|
| 45 |
+
setDimensions({
|
| 46 |
+
width: containerRef.current.offsetWidth,
|
| 47 |
+
height: containerRef.current.offsetHeight,
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
handleResize();
|
| 52 |
+
window.addEventListener('resize', handleResize);
|
| 53 |
+
return () => window.removeEventListener('resize', handleResize);
|
| 54 |
+
}, []);
|
| 55 |
+
|
| 56 |
+
/* ---------- pre-computed launch paths ---------- */
|
| 57 |
+
|
| 58 |
+
const numPaths = 7;
|
| 59 |
+
const paths = useMemo(() => {
|
| 60 |
+
const { width, height } = dimensions;
|
| 61 |
+
if (!width || !height) return [];
|
| 62 |
+
|
| 63 |
+
return Array.from({ length: numPaths }, (_, id) => ({
|
| 64 |
+
id,
|
| 65 |
+
startX: width / 2,
|
| 66 |
+
startY: height,
|
| 67 |
+
endX: random(width * 0.2, width * 0.8),
|
| 68 |
+
endY: random(height * 0.1, height * 0.4),
|
| 69 |
+
launchDuration: random(0.8, 3), // s
|
| 70 |
+
explosionDuration: random(1, 5), // s
|
| 71 |
+
numParticles: Math.floor(random(15, 30)),
|
| 72 |
+
numRings: Math.floor(random(2, 7)),
|
| 73 |
+
particleRadius: random(80, 180),
|
| 74 |
+
defaultColor:
|
| 75 |
+
fireworkColors[Math.floor(random(0, fireworkColors.length))],
|
| 76 |
+
}));
|
| 77 |
+
}, [dimensions]);
|
| 78 |
+
|
| 79 |
+
/* ---------- bookkeeping ---------- */
|
| 80 |
+
|
| 81 |
+
const [activeFireworks, setActiveFireworks] = useState([]); // {key, pathId, color}
|
| 82 |
+
const activePathIds = useRef(new Set());
|
| 83 |
+
|
| 84 |
+
const removeFirework = useCallback((key, pathId) => {
|
| 85 |
+
setActiveFireworks((prev) => prev.filter((fw) => fw.key !== key));
|
| 86 |
+
activePathIds.current.delete(pathId);
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
+
/* ---------- launch logic ---------- */
|
| 90 |
+
|
| 91 |
+
const launchFirework = useCallback(
|
| 92 |
+
(specificPathId = null, overrideColor = null) => {
|
| 93 |
+
if (!paths.length) return;
|
| 94 |
+
|
| 95 |
+
let pathId = specificPathId;
|
| 96 |
+
if (pathId == null) {
|
| 97 |
+
const available = paths
|
| 98 |
+
.map((p) => p.id)
|
| 99 |
+
.filter((id) => !activePathIds.current.has(id));
|
| 100 |
+
if (!available.length) return; // all busy
|
| 101 |
+
pathId = available[Math.floor(random(0, available.length))];
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const path = paths.find(p => p.id === pathId);
|
| 105 |
+
if (!path || activePathIds.current.has(pathId)) return; // already occupied or path not found
|
| 106 |
+
|
| 107 |
+
const key = uuid();
|
| 108 |
+
const color = overrideColor || path.defaultColor;
|
| 109 |
+
|
| 110 |
+
activePathIds.current.add(pathId);
|
| 111 |
+
setActiveFireworks((prev) => [...prev, { key, pathId, color }]);
|
| 112 |
+
},
|
| 113 |
+
[paths]
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
/* interval launch */
|
| 117 |
+
const intervalRef = useRef();
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
clearInterval(intervalRef.current);
|
| 120 |
+
if (!paths.length) return;
|
| 121 |
+
|
| 122 |
+
intervalRef.current = setInterval(() => launchFirework(null, null), launchInterval);
|
| 123 |
+
return () => clearInterval(intervalRef.current);
|
| 124 |
+
}, [launchInterval, launchFirework, paths.length]);
|
| 125 |
+
|
| 126 |
+
/* burst launch */
|
| 127 |
+
useEffect(() => {
|
| 128 |
+
if (triggerBurst && paths.length) {
|
| 129 |
+
paths.forEach((p, i) =>
|
| 130 |
+
setTimeout(() => launchFirework(p.id, burstColorOverride), i * 50)
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
}, [triggerBurst, burstColorOverride, paths, launchFirework]);
|
| 134 |
+
|
| 135 |
+
/* ================================================================= */
|
| 136 |
+
/* render */
|
| 137 |
+
/* ================================================================= */
|
| 138 |
+
|
| 139 |
+
return (
|
| 140 |
+
<div
|
| 141 |
+
ref={containerRef}
|
| 142 |
+
className="absolute inset-0 overflow-hidden z-0 pointer-events-none"
|
| 143 |
+
style={{ backgroundColor: 'rgba(17,24,39,0.7)' }}
|
| 144 |
+
>
|
| 145 |
+
{dimensions.width > 0 && (
|
| 146 |
+
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
| 147 |
+
{activeFireworks.map(({ key, pathId, color }) => {
|
| 148 |
+
const path = paths.find((p) => p.id === pathId);
|
| 149 |
+
if (!path) return null;
|
| 150 |
+
return (
|
| 151 |
+
<Firework
|
| 152 |
+
key={key}
|
| 153 |
+
path={path}
|
| 154 |
+
color={color}
|
| 155 |
+
onDone={() => removeFirework(key, pathId)}
|
| 156 |
+
/>
|
| 157 |
+
);
|
| 158 |
+
})}
|
| 159 |
+
</svg>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
);
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
export default FireworksBackground;
|
| 166 |
+
|
| 167 |
+
/* =================================================================== */
|
| 168 |
+
/* Firework (declarative) */
|
| 169 |
+
/* =================================================================== */
|
| 170 |
+
|
| 171 |
+
const Firework = ({ path, color, onDone }) => {
|
| 172 |
+
const [phase, setPhase] = useState('launch'); // launch → explode → done
|
| 173 |
+
|
| 174 |
+
/* phase timers */
|
| 175 |
+
useEffect(() => {
|
| 176 |
+
const t1 = setTimeout(
|
| 177 |
+
() => setPhase('explode'),
|
| 178 |
+
path.launchDuration * 1000
|
| 179 |
+
);
|
| 180 |
+
const t2 = setTimeout(
|
| 181 |
+
() => setPhase('done'),
|
| 182 |
+
(path.launchDuration + path.explosionDuration) * 1000
|
| 183 |
+
);
|
| 184 |
+
return () => {
|
| 185 |
+
clearTimeout(t1);
|
| 186 |
+
clearTimeout(t2);
|
| 187 |
+
};
|
| 188 |
+
}, [path]);
|
| 189 |
+
|
| 190 |
+
/* notify parent */
|
| 191 |
+
useEffect(() => {
|
| 192 |
+
if (phase === 'done') onDone();
|
| 193 |
+
}, [phase, onDone]);
|
| 194 |
+
|
| 195 |
+
/* launch particle style */
|
| 196 |
+
const launchMotion = {
|
| 197 |
+
translateX: path.endX - path.startX,
|
| 198 |
+
translateY: path.endY - path.startY,
|
| 199 |
+
opacity: 0,
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
return (
|
| 203 |
+
<g>
|
| 204 |
+
{/* launch particle */}
|
| 205 |
+
<motion.circle
|
| 206 |
+
cx={path.startX}
|
| 207 |
+
cy={path.startY}
|
| 208 |
+
r={3}
|
| 209 |
+
fill={color}
|
| 210 |
+
initial={{ opacity: 1 }}
|
| 211 |
+
animate={phase === 'launch' ? launchMotion : { opacity: 0 }}
|
| 212 |
+
transition={{ duration: path.launchDuration, ease: 'linear' }}
|
| 213 |
+
/>
|
| 214 |
+
|
| 215 |
+
{/* explosion group */}
|
| 216 |
+
<motion.g
|
| 217 |
+
style={{ x: path.endX, y: path.endY }}
|
| 218 |
+
initial={{ opacity: 0 }}
|
| 219 |
+
animate={{ opacity: phase === 'explode' ? 1 : 0 }}
|
| 220 |
+
>
|
| 221 |
+
{/* Create multiple rings for a fuller explosion */}
|
| 222 |
+
{Array.from({ length: path.numRings }).flatMap((_, ringIndex) => {
|
| 223 |
+
const ringDelay = ringIndex * 0.1; // Delay subsequent rings
|
| 224 |
+
const ringRadiusMultiplier = 1 - ringIndex * 0.2; // Inner rings are slightly smaller (optional)
|
| 225 |
+
|
| 226 |
+
return Array.from({ length: path.numParticles }).map((_, particleIndex) => {
|
| 227 |
+
const angle = (particleIndex / path.numParticles) * 2 * Math.PI;
|
| 228 |
+
const particleRadius = path.particleRadius * ringRadiusMultiplier;
|
| 229 |
+
|
| 230 |
+
return (
|
| 231 |
+
<motion.circle
|
| 232 |
+
// Need a unique key combining ring and particle index
|
| 233 |
+
key={`${ringIndex}-${particleIndex}`}
|
| 234 |
+
cx={0}
|
| 235 |
+
cy={0}
|
| 236 |
+
r={2.5}
|
| 237 |
+
fill={color}
|
| 238 |
+
initial={{ opacity: 1 }}
|
| 239 |
+
animate={
|
| 240 |
+
phase === 'explode'
|
| 241 |
+
? {
|
| 242 |
+
translateX: Math.cos(angle) * particleRadius,
|
| 243 |
+
translateY: Math.sin(angle) * particleRadius,
|
| 244 |
+
opacity: 0, // fade out while travelling
|
| 245 |
+
}
|
| 246 |
+
: { opacity: 1 } // invisible anyway because group opacity = 0
|
| 247 |
+
}
|
| 248 |
+
transition={{
|
| 249 |
+
duration: path.explosionDuration,
|
| 250 |
+
ease: 'easeOut',
|
| 251 |
+
// Combine ring delay with random individual delay
|
| 252 |
+
delay: ringDelay + Math.random() * 0.15,
|
| 253 |
+
}}
|
| 254 |
+
/>
|
| 255 |
+
);
|
| 256 |
+
});
|
| 257 |
+
})}
|
| 258 |
+
</motion.g>
|
| 259 |
+
</g>
|
| 260 |
+
);
|
| 261 |
+
};
|
atk-interface/src/components/IdleMonitorGraphic.jsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { FiSettings } from 'react-icons/fi'; // Keep gear
|
| 4 |
+
|
| 5 |
+
const IdleMonitorGraphic = () => {
|
| 6 |
+
return (
|
| 7 |
+
<div className="relative flex items-center justify-center w-40 h-40 text-gray-600"> {/* Container size (controls gear size) */}
|
| 8 |
+
{/* Spinning Gear */}
|
| 9 |
+
<motion.div
|
| 10 |
+
animate={{ rotate: 360 }}
|
| 11 |
+
transition={{
|
| 12 |
+
repeat: Infinity,
|
| 13 |
+
duration: 15, // Slower spin
|
| 14 |
+
ease: 'linear',
|
| 15 |
+
}}
|
| 16 |
+
className="absolute inset-0 flex items-center justify-center"
|
| 17 |
+
>
|
| 18 |
+
{/* Gear takes color from parent div */}
|
| 19 |
+
<FiSettings size={200} /> {/* Increased size */}
|
| 20 |
+
</motion.div>
|
| 21 |
+
|
| 22 |
+
{/* Stationary Binoculars SVG - Centered and Resized */}
|
| 23 |
+
{/* Container centers the SVG within the gear */}
|
| 24 |
+
<div className="absolute flex items-center justify-center text-gray-500">
|
| 25 |
+
{/* SVG adjusted: size, viewBox might need tuning based on visual */}
|
| 26 |
+
<svg
|
| 27 |
+
version="1.1"
|
| 28 |
+
id="Capa_1"
|
| 29 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 30 |
+
xmlnsXlink="http://www.w3.org/1999/xlink"
|
| 31 |
+
width="65" /* Adjusted size */
|
| 32 |
+
height="65" /* Adjusted size */
|
| 33 |
+
viewBox="0 0 921.998 921.998"
|
| 34 |
+
xmlSpace="preserve"
|
| 35 |
+
// Removed explicit fill from SVG tag
|
| 36 |
+
>
|
| 37 |
+
{/* Group takes fill from parent div's text color */}
|
| 38 |
+
<g fill="currentColor">
|
| 39 |
+
<g>
|
| 40 |
+
{/* Path data remains the same */}
|
| 41 |
+
<path d="M869.694,385.652c-11.246-12.453-132.373-110.907-154.023-117.272c-9.421-2.735-18.892-4.447-28.681-5.164
|
| 42 |
+
c-45.272-3.315-95.213,10.875-126.684,44.794c-2.741,2.956-4.311,4.645-4.311,4.645s1.172-1.996,3.224-5.488
|
| 43 |
+
c9.706-16.365,23.847-30.577,38.989-41.956c6.979-5.243,14.37-9.937,22.088-14.014c2.116-1.118,21.797-11.751,23.12-10.357
|
| 44 |
+
c-0.003-0.003-10.744-11.33-10.744-11.33c-17.273-17.276-35.963-32.167-61.415-32.167c-31.547,0-58.505,19.559-69.472,47.201
|
| 45 |
+
c-9.306-6.917-24.11-11.392-40.788-11.392c-16.678,0-31.481,4.475-40.788,11.392c-10.967-27.643-37.925-47.201-69.472-47.201
|
| 46 |
+
c-25.452,0-44.142,14.891-61.416,32.166c0,0-10.741,11.327-10.744,11.33c1.322-1.395,21.003,9.239,23.12,10.357
|
| 47 |
+
c7.718,4.077,15.109,8.771,22.088,14.014c15.145,11.378,29.283,25.591,38.989,41.956c2.052,3.493,3.224,5.488,3.224,5.488
|
| 48 |
+
s-1.566-1.689-4.31-4.645c-31.471-33.919-81.411-48.109-126.683-44.794c-9.789,0.717-19.26,2.429-28.681,5.164
|
| 49 |
+
c-21.651,6.365-142.778,104.819-154.023,117.272C19.797,421.645,0,469.336,0,521.655c0,112.112,90.886,203,203,203
|
| 50 |
+
c102.56,0,187.34-76.062,201.048-174.851c15.983,11.645,35.663,18.52,56.951,18.52c21.289,0,40.968-6.875,56.951-18.52
|
| 51 |
+
c13.708,98.788,98.487,174.851,201.048,174.851c112.114,0,203-90.888,203-203C921.996,469.336,902.199,421.647,869.694,385.652z
|
| 52 |
+
M198.497,649.155c-67.611,0-122.421-54.811-122.421-122.421s54.81-122.42,122.421-122.42s122.421,54.81,122.421,122.42
|
| 53 |
+
S266.108,649.155,198.497,649.155z M460.997,515.234c-17.833,0-32.29-14.457-32.29-32.29s14.457-32.289,32.29-32.289
|
| 54 |
+
s32.29,14.457,32.29,32.289C493.287,500.777,478.83,515.234,460.997,515.234z M723.497,649.155
|
| 55 |
+
c-67.611,0-122.421-54.811-122.421-122.421s54.81-122.42,122.421-122.42s122.421,54.81,122.421,122.42
|
| 56 |
+
S791.108,649.155,723.497,649.155z"/>
|
| 57 |
+
</g>
|
| 58 |
+
</g>
|
| 59 |
+
</svg>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
export default IdleMonitorGraphic;
|
atk-interface/src/components/Layout.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
| 3 |
+
import { useNavigation } from '../context/NavigationContext';
|
| 4 |
+
import atkLogo from '../assets/atk-logo-notscuffed.png';
|
| 5 |
+
|
| 6 |
+
const animationStyles = `
|
| 7 |
+
@keyframes pulse-medium-white {
|
| 8 |
+
0%, 100% { background-color: rgba(255,255,255,0.1); }
|
| 9 |
+
50% { background-color: rgba(255,255,255,0.3); }
|
| 10 |
+
}
|
| 11 |
+
.animate-pulse-medium-white {
|
| 12 |
+
animation: pulse-medium-white 1.8s infinite;
|
| 13 |
+
}
|
| 14 |
+
`;
|
| 15 |
+
|
| 16 |
+
function Layout() {
|
| 17 |
+
const { highlightedNavLink, setHighlightedNavLink } = useNavigation();
|
| 18 |
+
const location = useLocation();
|
| 19 |
+
const [serverOnline, setServerOnline] = useState(false);
|
| 20 |
+
const [datagenServerOnline, setDatagenServerOnline] = useState(false);
|
| 21 |
+
|
| 22 |
+
// Check for server on port 8003
|
| 23 |
+
useEffect(() => {
|
| 24 |
+
const checkServerStatus = async () => {
|
| 25 |
+
try {
|
| 26 |
+
const baseUrl = window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
|
| 27 |
+
? 'http://127.0.0.1:8003'
|
| 28 |
+
: `${window.location.origin}/llm`;
|
| 29 |
+
const response = await fetch(`${baseUrl}/health`, {
|
| 30 |
+
method: 'GET',
|
| 31 |
+
headers: { 'Content-Type': 'application/json' }
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
const isOnline = response.ok;
|
| 35 |
+
setServerOnline(isOnline);
|
| 36 |
+
|
| 37 |
+
// Auto-highlight chat button when server comes online (but not if user is already on chat page)
|
| 38 |
+
if (isOnline && location.pathname !== '/chat') {
|
| 39 |
+
setHighlightedNavLink('/chat');
|
| 40 |
+
} else if (!isOnline && highlightedNavLink === '/chat') {
|
| 41 |
+
// Remove highlight when server goes offline
|
| 42 |
+
setHighlightedNavLink(null);
|
| 43 |
+
}
|
| 44 |
+
} catch (error) {
|
| 45 |
+
setServerOnline(false);
|
| 46 |
+
// Remove highlight when server is unreachable
|
| 47 |
+
if (highlightedNavLink === '/chat') {
|
| 48 |
+
setHighlightedNavLink(null);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// Initial check
|
| 54 |
+
checkServerStatus();
|
| 55 |
+
|
| 56 |
+
// Poll every 5 seconds
|
| 57 |
+
const interval = setInterval(checkServerStatus, 5000);
|
| 58 |
+
|
| 59 |
+
return () => clearInterval(interval);
|
| 60 |
+
}, [location.pathname, highlightedNavLink, setHighlightedNavLink]);
|
| 61 |
+
|
| 62 |
+
// Check for datagen inference server on port 8082
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
const checkDatagenServerStatus = async () => {
|
| 65 |
+
try {
|
| 66 |
+
// Try OpenAI-compatible endpoint
|
| 67 |
+
const response = await fetch('http://127.0.0.1:8082/v1/models', {
|
| 68 |
+
method: 'GET',
|
| 69 |
+
headers: { 'Content-Type': 'application/json' }
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
setDatagenServerOnline(response.ok);
|
| 73 |
+
} catch (error) {
|
| 74 |
+
setDatagenServerOnline(false);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
// Initial check
|
| 79 |
+
checkDatagenServerStatus();
|
| 80 |
+
|
| 81 |
+
// Poll every 5 seconds
|
| 82 |
+
const interval = setInterval(checkDatagenServerStatus, 5000);
|
| 83 |
+
|
| 84 |
+
return () => clearInterval(interval);
|
| 85 |
+
}, []);
|
| 86 |
+
|
| 87 |
+
const navLinkClass = (path) => ({ isActive }) => {
|
| 88 |
+
const base = 'px-4 py-2 rounded hover:bg-gray-700 transition-colors';
|
| 89 |
+
const active = isActive ? 'bg-blue-600 text-white font-bold' : 'text-gray-300';
|
| 90 |
+
const pulse = highlightedNavLink === path && !isActive ? 'animate-pulse-medium-white' : '';
|
| 91 |
+
return `${base} ${active} ${pulse}`;
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<div className="flex flex-col h-screen bg-gray-900 text-white">
|
| 96 |
+
<nav className="bg-gray-800 shadow-md p-4 flex items-center gap-4">
|
| 97 |
+
<NavLink to="/" className="mr-4 text-xl font-bold text-white hover:text-gray-300 flex items-center">
|
| 98 |
+
<img src={atkLogo} alt="ATK Logo" className="h-10 mr-2 bg-gray-100 p-1 rounded-md" />
|
| 99 |
+
</NavLink>
|
| 100 |
+
<NavLink to="/configs" className={navLinkClass('/configs')}>
|
| 101 |
+
Configs
|
| 102 |
+
</NavLink>
|
| 103 |
+
<NavLink to="/inputs" className={navLinkClass('/inputs')}>
|
| 104 |
+
Inputs
|
| 105 |
+
</NavLink>
|
| 106 |
+
<NavLink to="/outputs" className={navLinkClass('/outputs')}>
|
| 107 |
+
Outputs
|
| 108 |
+
</NavLink>
|
| 109 |
+
<NavLink to="/logs" className={navLinkClass('/logs')}>
|
| 110 |
+
Logs
|
| 111 |
+
</NavLink>
|
| 112 |
+
<NavLink to="/monitor" className={navLinkClass('/monitor')}>
|
| 113 |
+
Monitor
|
| 114 |
+
</NavLink>
|
| 115 |
+
<NavLink to="/chat" className={navLinkClass('/chat')}>
|
| 116 |
+
Chat
|
| 117 |
+
</NavLink>
|
| 118 |
+
|
| 119 |
+
{/* Datagen Inference Server Status */}
|
| 120 |
+
<div className="ml-auto flex flex-col items-end gap-1">
|
| 121 |
+
<div className="flex items-center gap-2">
|
| 122 |
+
<div className={`w-2 h-2 rounded-full ${
|
| 123 |
+
datagenServerOnline ? 'bg-green-500' : 'bg-red-500'
|
| 124 |
+
}`} />
|
| 125 |
+
<span className="text-xs text-gray-400">
|
| 126 |
+
Datagen Server
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
<span className="text-xs text-gray-500 text-right max-w-48">
|
| 130 |
+
{datagenServerOnline
|
| 131 |
+
? 'Online (Port 8082)'
|
| 132 |
+
: 'Offline - Start with local_* scripts (takes 1-2 minutes after project startup)'
|
| 133 |
+
}
|
| 134 |
+
</span>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
{/* TODO: Add Settings/Boring Mode Toggle */}
|
| 138 |
+
</nav>
|
| 139 |
+
<main className="flex-grow overflow-auto">
|
| 140 |
+
<Outlet /> {/* Child routes will render here */}
|
| 141 |
+
</main>
|
| 142 |
+
<style>{animationStyles}</style>
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export default Layout;
|
atk-interface/src/components/Modal.jsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { FiX } from 'react-icons/fi';
|
| 3 |
+
|
| 4 |
+
function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }) {
|
| 5 |
+
|
| 6 |
+
// Handle Escape key press - Hooks must be called unconditionally
|
| 7 |
+
React.useEffect(() => {
|
| 8 |
+
const handleEscape = (event) => {
|
| 9 |
+
if (event.key === 'Escape') {
|
| 10 |
+
onClose();
|
| 11 |
+
}
|
| 12 |
+
};
|
| 13 |
+
// Only add listener if modal is open
|
| 14 |
+
if (isOpen) {
|
| 15 |
+
document.addEventListener('keydown', handleEscape);
|
| 16 |
+
}
|
| 17 |
+
// Cleanup function removes listener
|
| 18 |
+
return () => {
|
| 19 |
+
document.removeEventListener('keydown', handleEscape);
|
| 20 |
+
};
|
| 21 |
+
}, [isOpen, onClose]); // Depend on isOpen and onClose
|
| 22 |
+
|
| 23 |
+
// Early return if not open, *after* hooks
|
| 24 |
+
if (!isOpen) return null;
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<div
|
| 28 |
+
className="fixed inset-0 bg-black bg-opacity-70 z-50 flex items-center justify-center p-4 transition-opacity duration-300 ease-in-out opacity-100" // Start visible for animation
|
| 29 |
+
onClick={onClose} // Close on backdrop click
|
| 30 |
+
aria-modal="true"
|
| 31 |
+
role="dialog"
|
| 32 |
+
>
|
| 33 |
+
<div
|
| 34 |
+
className={`bg-gray-800 rounded-lg shadow-xl p-6 w-full ${width} relative transform transition-all duration-300 ease-in-out scale-95 opacity-0 animate-fade-scale-in`}
|
| 35 |
+
onClick={e => e.stopPropagation()} // Prevent closing when clicking inside modal
|
| 36 |
+
>
|
| 37 |
+
<button
|
| 38 |
+
onClick={onClose}
|
| 39 |
+
className="absolute top-3 right-3 text-gray-500 hover:text-gray-300 text-2xl p-1 rounded-full hover:bg-gray-700"
|
| 40 |
+
aria-label="Close modal"
|
| 41 |
+
>
|
| 42 |
+
<FiX />
|
| 43 |
+
</button>
|
| 44 |
+
<h2 className="text-xl font-semibold mb-4 text-gray-100">{title}</h2>
|
| 45 |
+
<div className="text-gray-300">
|
| 46 |
+
{children}
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Add simple fade-in animation (requires Tailwind config update)
|
| 54 |
+
// In tailwind.config.js -> theme -> extend -> keyframes & animation:
|
| 55 |
+
/*
|
| 56 |
+
keyframes: {
|
| 57 |
+
'fade-scale-in': {
|
| 58 |
+
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
| 59 |
+
'100%': { opacity: '1', transform: 'scale(1)' },
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
animation: {
|
| 63 |
+
'fade-scale-in': 'fade-scale-in 0.2s ease-out forwards',
|
| 64 |
+
}
|
| 65 |
+
*/
|
| 66 |
+
|
| 67 |
+
export default Modal;
|
atk-interface/src/components/PourOverBackground.jsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
// Placeholder SVGs (we can refine these later)
|
| 5 |
+
const FunnelSVG = () => (
|
| 6 |
+
<svg width="600" height="300" viewBox="0 0 100 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 7 |
+
<path d="M10 10 H90 L60 50 H40 L10 10 Z" stroke="#9CA3AF" strokeWidth="2" fill="#4B5563" /> {/* Funnel body */}
|
| 8 |
+
<rect x="45" y="50" width="10" height="20" fill="#4B5563" stroke="#9CA3AF" strokeWidth="2" /> {/* Spout */}
|
| 9 |
+
{/* Simple static "water" line */}
|
| 10 |
+
<line x1="25" y1="30" x2="75" y2="30" stroke="#60A5FA" strokeWidth="3" />
|
| 11 |
+
</svg>
|
| 12 |
+
);
|
| 13 |
+
|
| 14 |
+
const ContainerSVG = () => (
|
| 15 |
+
<svg width="200" height="300" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 16 |
+
{/* Taller coffee level */}
|
| 17 |
+
<rect x="10" y="35" width="40" height="35" fill="#A16207" /> {/* Coffee */}
|
| 18 |
+
{/* Taller container outline */}
|
| 19 |
+
<path d="M5 5 H55 V75 H5 V5 Z" stroke="#9CA3AF" strokeWidth="2" fill="#374151" /> {/* Glass */}
|
| 20 |
+
{/* Handle */}
|
| 21 |
+
<path d="M55 25 C 70 30, 70 50, 55 55" stroke="#9CA3AF" strokeWidth="4" fill="none" />
|
| 22 |
+
</svg>
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
const SpillPuddleSVG = () => (
|
| 26 |
+
<svg width="200" height="60" viewBox="0 0 100 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 27 |
+
<path d="M5 15 Q 30 30, 50 15 T 95 20" stroke="#A16207" strokeWidth="4" fill="none" strokeLinecap="round"/>
|
| 28 |
+
</svg>
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
const DripSVG = () => (
|
| 32 |
+
<motion.svg
|
| 33 |
+
width="50" height="50" viewBox="0 0 10 15" fill="none" xmlns="http://www.w3.org/2000/svg"
|
| 34 |
+
style={{ originY: 0 }} // Animate scale from the top
|
| 35 |
+
>
|
| 36 |
+
<path d="M5 0 Q 0 5, 5 15 Q 10 5, 5 0 Z" fill="#A16207" /> {/* Water drip */}
|
| 37 |
+
</motion.svg>
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
const LidSVG = () => (
|
| 41 |
+
<svg width="130" height="50" viewBox="0 0 65 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 42 |
+
<rect x="2.5" y="2.5" width="60" height="10" rx="3" fill="#4B5563" stroke="#9CA3AF" strokeWidth="2" />
|
| 43 |
+
</svg>
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
const PourOverBackground = ({
|
| 48 |
+
isActive = false,
|
| 49 |
+
triggerDownload = false,
|
| 50 |
+
triggerDelete = false,
|
| 51 |
+
onDownloadComplete,
|
| 52 |
+
onDeleteComplete,
|
| 53 |
+
}) => {
|
| 54 |
+
const [containerState, setContainerState] = useState('idle'); // 'idle', 'downloading', 'spilled'
|
| 55 |
+
|
| 56 |
+
// --- Animation Variants ---
|
| 57 |
+
|
| 58 |
+
// Drip Animation
|
| 59 |
+
const dripVariants = {
|
| 60 |
+
idle: {
|
| 61 |
+
y: [0, 130], // Start above container, end further down (Adjusted for scale)
|
| 62 |
+
opacity: [1, 1, 0],
|
| 63 |
+
scaleY: [0.5, 1, 1],
|
| 64 |
+
transition: { duration: 1.5, repeat: Infinity, ease: "linear", times: [0, 0.8, 1] }
|
| 65 |
+
},
|
| 66 |
+
active: {
|
| 67 |
+
// y: [0, 130], // Removed y animation for stationary effect
|
| 68 |
+
y: 50,
|
| 69 |
+
opacity: [0.6, 1, 0.6], // Pulsating opacity
|
| 70 |
+
scaleY: [0.1, 4, 0.1], // Pulse scale from near zero to full height
|
| 71 |
+
transition: { duration: 0.4, repeat: Infinity, ease: "easeInOut", times: [0, 0.5, 1] } // Symmetrical pulse timing
|
| 72 |
+
},
|
| 73 |
+
hidden: { opacity: 0, y: 0, transition: { duration: 0.1 } } // Hide drip during transitions
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// Container Animation
|
| 77 |
+
const containerVariants = {
|
| 78 |
+
idle: { x: 0, rotate: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } },
|
| 79 |
+
exitRight: { x: '150%', opacity: 0, transition: { duration: 0.8, ease: "easeIn" } },
|
| 80 |
+
spill: { rotate: -90, x: -40, y: 20, transition: { duration: 0.4, ease: "easeIn" } }, // Tilt further left
|
| 81 |
+
hidden: { opacity: 0, transition: { duration: 0.1 } }
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Lid Animation
|
| 85 |
+
const lidVariants = {
|
| 86 |
+
hidden: { y: -20, opacity: 0, transition: { duration: 0.3, ease: "easeOut" } },
|
| 87 |
+
visible: { y: -5, opacity: 1, transition: { duration: 0.3, ease: "easeIn" } } // Position above container
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
// Spill Puddle Animation
|
| 91 |
+
const spillPuddleVariants = {
|
| 92 |
+
hidden: { opacity: 0, scale: 0.8, transition: { duration: 0.3, ease: "easeOut" } },
|
| 93 |
+
visible: { opacity: 1, scale: 1, transition: { duration: 0.3, delay: 0.2, ease: "easeIn" } } // Appear slightly after spill starts
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// --- Funnel Animation Variants ---
|
| 97 |
+
const funnelVariants = {
|
| 98 |
+
bobbing: {
|
| 99 |
+
x: "-50%", // Maintain horizontal center (instead of relying on CSS translate)
|
| 100 |
+
y: [-3, 3, -3], // Bob up and down slightly
|
| 101 |
+
transition: { duration: 2.5, repeat: Infinity, ease: "easeInOut", repeatType: "loop" }
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
// --- State Management ---
|
| 106 |
+
|
| 107 |
+
// Handle Download Trigger
|
| 108 |
+
useEffect(() => {
|
| 109 |
+
if (triggerDownload) {
|
| 110 |
+
setContainerState('downloading');
|
| 111 |
+
// Sequence: Show lid -> Move container -> Hide lid -> Reset -> Callback
|
| 112 |
+
const timer1 = setTimeout(() => { // Allow time for lid animation
|
| 113 |
+
const timer2 = setTimeout(() => { // Allow time for container exit
|
| 114 |
+
setContainerState('idle'); // Bring back a fresh container
|
| 115 |
+
onDownloadComplete?.();
|
| 116 |
+
}, 800); // Duration of container exit
|
| 117 |
+
return () => clearTimeout(timer2);
|
| 118 |
+
}, 300); // Duration of lid appear
|
| 119 |
+
return () => clearTimeout(timer1);
|
| 120 |
+
}
|
| 121 |
+
}, [triggerDownload, onDownloadComplete]);
|
| 122 |
+
|
| 123 |
+
// Handle Delete Trigger
|
| 124 |
+
useEffect(() => {
|
| 125 |
+
if (triggerDelete) {
|
| 126 |
+
setContainerState('spilled');
|
| 127 |
+
// Sequence: Spill -> Wait -> Reset -> Callback
|
| 128 |
+
const timer1 = setTimeout(() => { // Let spill animation play
|
| 129 |
+
setContainerState('idle'); // Bring back fresh container
|
| 130 |
+
onDeleteComplete?.();
|
| 131 |
+
}, 1500); // Duration to show spilled state
|
| 132 |
+
return () => clearTimeout(timer1);
|
| 133 |
+
}
|
| 134 |
+
}, [triggerDelete, onDeleteComplete]);
|
| 135 |
+
|
| 136 |
+
const showDrip = containerState === 'idle';
|
| 137 |
+
const showLid = containerState === 'downloading';
|
| 138 |
+
const showSpill = containerState === 'spilled';
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<div className="absolute inset-0 flex justify-center items-center overflow-hidden z-0 pointer-events-none">
|
| 142 |
+
<div className="relative w-[300px] h-[440px]"> {/* Positioning context - Increased size */}
|
| 143 |
+
|
| 144 |
+
{/* Funnel (Static for now) */}
|
| 145 |
+
<motion.div
|
| 146 |
+
className="absolute top-0 left-[50%]" // Removed translate-x-1/2 as Framer Motion handles x now
|
| 147 |
+
initial={{ x: "-50%", y: -3 }} // Set initial position explicitly
|
| 148 |
+
variants={funnelVariants} // Apply variants
|
| 149 |
+
animate="bobbing" // Start the animation
|
| 150 |
+
>
|
| 151 |
+
<FunnelSVG />
|
| 152 |
+
</motion.div>
|
| 153 |
+
|
| 154 |
+
{/* Drip */}
|
| 155 |
+
<AnimatePresence>
|
| 156 |
+
{showDrip && (
|
| 157 |
+
<motion.div
|
| 158 |
+
key={isActive ? "drip-active" : "drip-idle"}
|
| 159 |
+
className="absolute top-[200px] left-[50%] -translate-x-1/2 origin-top -z-10" // Position below funnel spout & set origin
|
| 160 |
+
style={{ translateX: '-25px' }} // Center the larger drip
|
| 161 |
+
variants={dripVariants}
|
| 162 |
+
initial="hidden"
|
| 163 |
+
animate={isActive ? 'active' : 'idle'}
|
| 164 |
+
exit="hidden"
|
| 165 |
+
>
|
| 166 |
+
<DripSVG />
|
| 167 |
+
</motion.div>
|
| 168 |
+
)}
|
| 169 |
+
</AnimatePresence>
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
{/* Container Group (Lid + Container) */}
|
| 173 |
+
<motion.div
|
| 174 |
+
className="absolute top-[250px] left-[50%] -translate-x-1/2" // Position below funnel/drip (Adjusted for scale)
|
| 175 |
+
style={{
|
| 176 |
+
// Add origin for rotation if needed, default is center
|
| 177 |
+
originX: 0.5,
|
| 178 |
+
originY: 0.5,
|
| 179 |
+
translateX: '-70px' // Adjust left slightly more
|
| 180 |
+
}}
|
| 181 |
+
variants={containerVariants}
|
| 182 |
+
initial="idle"
|
| 183 |
+
animate={
|
| 184 |
+
containerState === 'downloading' ? 'exitRight' :
|
| 185 |
+
containerState === 'spilled' ? 'spill' :
|
| 186 |
+
'idle'
|
| 187 |
+
}
|
| 188 |
+
>
|
| 189 |
+
{/* Lid */}
|
| 190 |
+
<motion.div
|
| 191 |
+
className="absolute top-[50px] left-[50%] -translate-x-1/2 z-10" // Position lid relative to container
|
| 192 |
+
style={{
|
| 193 |
+
translateX: '-90px'
|
| 194 |
+
}}
|
| 195 |
+
variants={lidVariants}
|
| 196 |
+
initial="hidden"
|
| 197 |
+
animate={showLid ? "visible" : "hidden"}
|
| 198 |
+
>
|
| 199 |
+
<LidSVG />
|
| 200 |
+
</motion.div>
|
| 201 |
+
|
| 202 |
+
{/* Container */}
|
| 203 |
+
<ContainerSVG />
|
| 204 |
+
</motion.div>
|
| 205 |
+
|
| 206 |
+
{/* Spill Puddle - Positioned independently below where container spills */}
|
| 207 |
+
<motion.div
|
| 208 |
+
className="absolute bottom-[-70px] left-[50%]" // Position roughly where spilled container base lands (Adjusted bottom)
|
| 209 |
+
style={{
|
| 210 |
+
translateX: '-300px'
|
| 211 |
+
}}
|
| 212 |
+
variants={spillPuddleVariants}
|
| 213 |
+
initial="hidden"
|
| 214 |
+
animate={showSpill ? "visible" : "hidden"}
|
| 215 |
+
>
|
| 216 |
+
<SpillPuddleSVG />
|
| 217 |
+
</motion.div>
|
| 218 |
+
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
);
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
export default PourOverBackground;
|
atk-interface/src/components/ProgressBarParticles.jsx
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
| 2 |
+
import { motion, useAnimation } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
// --- Helper Functions ---
|
| 5 |
+
const random = (min, max) => Math.random() * (max - min) + min;
|
| 6 |
+
const uuid = () =>
|
| 7 |
+
typeof crypto !== 'undefined' && crypto.randomUUID
|
| 8 |
+
? crypto.randomUUID()
|
| 9 |
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
| 10 |
+
|
| 11 |
+
// Interpolate between two colors
|
| 12 |
+
const interpolateColor = (color1, color2, factor) => {
|
| 13 |
+
if (factor < 0) factor = 0;
|
| 14 |
+
if (factor > 1) factor = 1;
|
| 15 |
+
|
| 16 |
+
const c1 = parseInt(color1.substring(1), 16);
|
| 17 |
+
const c2 = parseInt(color2.substring(1), 16);
|
| 18 |
+
|
| 19 |
+
const r1 = (c1 >> 16) & 0xff;
|
| 20 |
+
const g1 = (c1 >> 8) & 0xff;
|
| 21 |
+
const b1 = c1 & 0xff;
|
| 22 |
+
|
| 23 |
+
const r2 = (c2 >> 16) & 0xff;
|
| 24 |
+
const g2 = (c2 >> 8) & 0xff;
|
| 25 |
+
const b2 = c2 & 0xff;
|
| 26 |
+
|
| 27 |
+
const r = Math.round(r1 + (r2 - r1) * factor);
|
| 28 |
+
const g = Math.round(g1 + (g2 - g1) * factor);
|
| 29 |
+
const b = Math.round(b1 + (b2 - b1) * factor);
|
| 30 |
+
|
| 31 |
+
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`;
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const START_COLOR = '#4ade80'; // Dull Green (Tailwind green-400)
|
| 35 |
+
const END_COLOR = '#00FF00'; // Bright Neon Green
|
| 36 |
+
|
| 37 |
+
// --- Particle Component ---
|
| 38 |
+
const Particle = ({ id, initialX, initialY, targetPointX, targetPointY, color, status, screenHeight, screenWidth, onComplete }) => {
|
| 39 |
+
const controls = useAnimation();
|
| 40 |
+
const [currentState, setCurrentState] = useState(status);
|
| 41 |
+
const particleRef = useRef({ isComplete: false }); // Ref to track completion status
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
setCurrentState(status); // Update state if prop changes
|
| 45 |
+
}, [status]);
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (particleRef.current.isComplete) return; // Don't restart animation if already completing
|
| 49 |
+
|
| 50 |
+
let animationSequence;
|
| 51 |
+
const durationBase = random(6, 12); // Base duration for normal movement
|
| 52 |
+
const fadeDuration = 1.5; // Normal fade duration
|
| 53 |
+
|
| 54 |
+
// Slow fade durations for lingering particles
|
| 55 |
+
const lingerFadeDuration = random(8, 15); // Longer fade for failed/revoked
|
| 56 |
+
const lingerDelay = random(3, 7); // Delay before slow fade starts
|
| 57 |
+
|
| 58 |
+
switch (currentState) {
|
| 59 |
+
case 'FAILED':
|
| 60 |
+
// Drop to bottom, linger as smaller "corpse", then fade slowly
|
| 61 |
+
animationSequence = async () => {
|
| 62 |
+
// 1. Drop to near the bottom
|
| 63 |
+
await controls.start({
|
| 64 |
+
x: random(0, screenWidth), // Scatter slightly at the bottom
|
| 65 |
+
y: screenHeight - random(10, 15), // Settle very close to the bottom edge
|
| 66 |
+
opacity: 1,
|
| 67 |
+
scale: 0.8, // Slightly smaller "corpse"
|
| 68 |
+
transition: { duration: random(1, 2.5), ease: "easeIn" }
|
| 69 |
+
});
|
| 70 |
+
// 2. Linger, then fade slowly
|
| 71 |
+
await controls.start({
|
| 72 |
+
opacity: 0,
|
| 73 |
+
transition: { duration: lingerFadeDuration, delay: lingerDelay }
|
| 74 |
+
});
|
| 75 |
+
if (!particleRef.current.isComplete) {
|
| 76 |
+
particleRef.current.isComplete = true;
|
| 77 |
+
onComplete(id);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
break;
|
| 81 |
+
|
| 82 |
+
case 'REVOKED':
|
| 83 |
+
// Flee to edges, linger smaller/huddled, then fade slowly
|
| 84 |
+
{
|
| 85 |
+
const edgeX = initialX < screenWidth / 2 ? random(-15, 0) : random(screenWidth, screenWidth + 15);
|
| 86 |
+
const edgeY = random(0, screenHeight);
|
| 87 |
+
animationSequence = async () => {
|
| 88 |
+
// 1. Move quickly to the edge
|
| 89 |
+
await controls.start({
|
| 90 |
+
x: edgeX,
|
| 91 |
+
y: edgeY,
|
| 92 |
+
opacity: 1,
|
| 93 |
+
scale: 0.6, // Smaller, huddled look
|
| 94 |
+
transition: { duration: random(0.5, 1.5), ease: "easeOut" }
|
| 95 |
+
});
|
| 96 |
+
// 2. Linger, then fade slowly
|
| 97 |
+
await controls.start({
|
| 98 |
+
opacity: 0,
|
| 99 |
+
transition: { duration: lingerFadeDuration, delay: lingerDelay }
|
| 100 |
+
});
|
| 101 |
+
if (!particleRef.current.isComplete) {
|
| 102 |
+
particleRef.current.isComplete = true;
|
| 103 |
+
onComplete(id);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
break;
|
| 108 |
+
|
| 109 |
+
case 'RUNNING':
|
| 110 |
+
case 'PENDING':
|
| 111 |
+
case 'COMPLETED':
|
| 112 |
+
default:
|
| 113 |
+
// Move towards the target point on the bar and fade (original behavior)
|
| 114 |
+
animationSequence = async () => {
|
| 115 |
+
// 1. Quick fade-in
|
| 116 |
+
await controls.start({
|
| 117 |
+
opacity: 1,
|
| 118 |
+
scale: 1, // Start at full size immediately
|
| 119 |
+
transition: { duration: 0.3, ease: "easeOut" } // Fast fade-in
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
// 2. Move towards the target point
|
| 123 |
+
await controls.start({
|
| 124 |
+
x: targetPointX,
|
| 125 |
+
y: targetPointY,
|
| 126 |
+
// Opacity and scale are already 1 from the previous step
|
| 127 |
+
transition: { duration: durationBase, ease: "circOut" }
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// 3. Fade out near the target
|
| 131 |
+
await controls.start({
|
| 132 |
+
opacity: 0,
|
| 133 |
+
scale: 0.3, // Shrink slightly as it fades
|
| 134 |
+
transition: { duration: fadeDuration }
|
| 135 |
+
});
|
| 136 |
+
if (!particleRef.current.isComplete) {
|
| 137 |
+
particleRef.current.isComplete = true;
|
| 138 |
+
onComplete(id);
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
break;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
animationSequence().catch(err => {
|
| 145 |
+
// Handle potential animation errors (e.g., component unmounted)
|
| 146 |
+
if (err.name !== 'AbortError' && !particleRef.current.isComplete) {
|
| 147 |
+
console.error("Particle animation error:", err);
|
| 148 |
+
// Ensure cleanup happens even on error if not already completed
|
| 149 |
+
particleRef.current.isComplete = true;
|
| 150 |
+
onComplete(id);
|
| 151 |
+
}
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
// Cleanup function for when component unmounts or dependencies change
|
| 155 |
+
// This helps prevent calling onComplete multiple times if status changes rapidly
|
| 156 |
+
return () => {
|
| 157 |
+
controls.stop(); // Stop any ongoing animation
|
| 158 |
+
// If the component unmounts before the animation naturally completes,
|
| 159 |
+
// ensure we call onComplete if it hasn't been called yet.
|
| 160 |
+
// This is important for states like RUNNING->FAILED transition.
|
| 161 |
+
if (!particleRef.current.isComplete) {
|
| 162 |
+
// We might not call onComplete here directly,
|
| 163 |
+
// as the state change might trigger a new animation
|
| 164 |
+
// that WILL eventually call onComplete. Let the new effect handle it.
|
| 165 |
+
// If we *do* call it here, it might remove the particle before the
|
| 166 |
+
// FAILED/REVOKED animation can even start.
|
| 167 |
+
// Let's rely on the isComplete flag within the async sequences.
|
| 168 |
+
}
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
// Ensure dependencies cover all inputs for the animation logic
|
| 172 |
+
}, [controls, id, initialX, initialY, targetPointX, targetPointY, color, currentState, screenHeight, screenWidth, onComplete]);
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<motion.div
|
| 177 |
+
key={id}
|
| 178 |
+
// Make particles smaller
|
| 179 |
+
className="absolute w-1.5 h-1.5 rounded-full pointer-events-none" // Size reduced
|
| 180 |
+
style={{
|
| 181 |
+
backgroundColor: color,
|
| 182 |
+
// Slightly reduced glow for density
|
| 183 |
+
boxShadow: `0 0 4px ${color}, 0 0 8px ${color}`, // Adjusted glow
|
| 184 |
+
top: 0,
|
| 185 |
+
left: 0,
|
| 186 |
+
}}
|
| 187 |
+
initial={{ x: initialX, y: initialY, opacity: 0, scale: 0.5 }}
|
| 188 |
+
animate={controls}
|
| 189 |
+
/>
|
| 190 |
+
);
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
// --- Main Particle Container ---
|
| 194 |
+
const ProgressBarParticles = ({ taskId, progress, status, progressBarElement, downloadButtonElement, color }) => {
|
| 195 |
+
const [particles, setParticles] = useState([]);
|
| 196 |
+
const containerRef = useRef(null);
|
| 197 |
+
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
| 198 |
+
const [barRect, setBarRect] = useState(null);
|
| 199 |
+
const [buttonRect, setButtonRect] = useState(null); // State for button position
|
| 200 |
+
|
| 201 |
+
// Get container dimensions
|
| 202 |
+
useEffect(() => {
|
| 203 |
+
const updateDimensions = () => {
|
| 204 |
+
if (containerRef.current) {
|
| 205 |
+
setDimensions({
|
| 206 |
+
width: containerRef.current.offsetWidth,
|
| 207 |
+
height: containerRef.current.offsetHeight,
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
};
|
| 211 |
+
updateDimensions();
|
| 212 |
+
window.addEventListener('resize', updateDimensions);
|
| 213 |
+
return () => window.removeEventListener('resize', updateDimensions);
|
| 214 |
+
}, []);
|
| 215 |
+
|
| 216 |
+
// Get progress bar dimensions/position relative to the viewport
|
| 217 |
+
useEffect(() => {
|
| 218 |
+
const updateBarRect = () => {
|
| 219 |
+
if (progressBarElement) {
|
| 220 |
+
setBarRect(progressBarElement.getBoundingClientRect());
|
| 221 |
+
} else {
|
| 222 |
+
setBarRect(null);
|
| 223 |
+
}
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
updateBarRect(); // Initial read
|
| 227 |
+
|
| 228 |
+
// Use ResizeObserver for efficiency if needed, or just poll on interval/scroll
|
| 229 |
+
const intervalId = setInterval(updateBarRect, 200); // Check position periodically
|
| 230 |
+
|
| 231 |
+
window.addEventListener('scroll', updateBarRect); // Update on scroll
|
| 232 |
+
|
| 233 |
+
return () => {
|
| 234 |
+
clearInterval(intervalId);
|
| 235 |
+
window.removeEventListener('scroll', updateBarRect);
|
| 236 |
+
};
|
| 237 |
+
}, [progressBarElement]); // Re-run if the element itself changes
|
| 238 |
+
|
| 239 |
+
// Get download button dimensions/position relative to the viewport
|
| 240 |
+
useEffect(() => {
|
| 241 |
+
const updateButtonRect = () => {
|
| 242 |
+
if (downloadButtonElement) {
|
| 243 |
+
setButtonRect(downloadButtonElement.getBoundingClientRect());
|
| 244 |
+
} else {
|
| 245 |
+
setButtonRect(null);
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
updateButtonRect(); // Initial read
|
| 250 |
+
|
| 251 |
+
// Use ResizeObserver for efficiency if needed, or just poll on interval/scroll
|
| 252 |
+
const intervalId = setInterval(updateButtonRect, 200); // Check position periodically
|
| 253 |
+
|
| 254 |
+
window.addEventListener('scroll', updateButtonRect); // Update on scroll
|
| 255 |
+
|
| 256 |
+
return () => {
|
| 257 |
+
clearInterval(intervalId);
|
| 258 |
+
window.removeEventListener('scroll', updateButtonRect);
|
| 259 |
+
};
|
| 260 |
+
}, [downloadButtonElement]); // Re-run if the button element itself changes
|
| 261 |
+
|
| 262 |
+
const removeParticle = useCallback((id) => {
|
| 263 |
+
setParticles(prev => prev.filter(p => p.id !== id));
|
| 264 |
+
}, []);
|
| 265 |
+
|
| 266 |
+
// Particle Generation Logic
|
| 267 |
+
useEffect(() => {
|
| 268 |
+
if (!taskId || !dimensions.width || !dimensions.height || !barRect) {
|
| 269 |
+
if (!taskId && particles.length > 0) {
|
| 270 |
+
setParticles([]);
|
| 271 |
+
}
|
| 272 |
+
return;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// --- Update existing particles status ---
|
| 276 |
+
// Do this regardless of the current task status, so particles
|
| 277 |
+
// correctly transition to FAILED/REVOKED animations etc.
|
| 278 |
+
setParticles(prev => prev.map(p => p.status !== status ? { ...p, status: status } : p));
|
| 279 |
+
|
| 280 |
+
// --- Particle Generation ---
|
| 281 |
+
// Always generate particles, but their behavior is determined by the status prop passed to Particle
|
| 282 |
+
|
| 283 |
+
// Increase max particles significantly & faster generation rate
|
| 284 |
+
const maxParticles = 15 + Math.floor((progress ?? 0) * 150); // Halved: Max ~165 particles at 100%
|
| 285 |
+
// Faster base rate, scales more aggressively with progress. Min 20ms interval.
|
| 286 |
+
const generationRate = Math.max(40, 200 - (progress ?? 0) * 160); // Slower generation: interval 40ms (100%) to 200ms (0%)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
const intervalId = setInterval(() => {
|
| 290 |
+
setParticles(prev => {
|
| 291 |
+
// Check against maxParticles (use the length *after* potential updates)
|
| 292 |
+
if (prev.length < maxParticles) {
|
| 293 |
+
const edge = Math.floor(random(0, 4)); // 0: top, 1: right, 2: bottom, 3: left
|
| 294 |
+
let startX, startY;
|
| 295 |
+
switch (edge) {
|
| 296 |
+
case 0: // top
|
| 297 |
+
startX = random(0, dimensions.width);
|
| 298 |
+
startY = -10;
|
| 299 |
+
break;
|
| 300 |
+
case 1: // right
|
| 301 |
+
startX = dimensions.width + 10;
|
| 302 |
+
startY = random(0, dimensions.height);
|
| 303 |
+
break;
|
| 304 |
+
case 2: // bottom
|
| 305 |
+
startX = random(0, dimensions.width);
|
| 306 |
+
startY = dimensions.height + 10;
|
| 307 |
+
break;
|
| 308 |
+
case 3: // left
|
| 309 |
+
default:
|
| 310 |
+
startX = -10;
|
| 311 |
+
startY = random(0, dimensions.height);
|
| 312 |
+
break;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// Calculate target point within the *filled* portion of the bar OR the button
|
| 316 |
+
const containerRect = containerRef.current?.getBoundingClientRect();
|
| 317 |
+
if (!containerRect) return prev; // Exit if container not ready
|
| 318 |
+
|
| 319 |
+
let targetX, targetY;
|
| 320 |
+
|
| 321 |
+
// Target button if status is COMPLETED and button exists
|
| 322 |
+
if (status === 'COMPLETED' && buttonRect) {
|
| 323 |
+
const buttonStartX = buttonRect.left - containerRect.left;
|
| 324 |
+
const buttonStartY = buttonRect.top - containerRect.top;
|
| 325 |
+
targetX = buttonStartX + random(0, buttonRect.width);
|
| 326 |
+
targetY = buttonStartY + random(0, buttonRect.height);
|
| 327 |
+
}
|
| 328 |
+
// Otherwise, target the progress bar (if it exists)
|
| 329 |
+
else if (barRect) {
|
| 330 |
+
const barStartX = barRect.left - containerRect.left;
|
| 331 |
+
const barStartY = barRect.top - containerRect.top;
|
| 332 |
+
const filledWidth = barRect.width * (progress ?? 0);
|
| 333 |
+
|
| 334 |
+
// Target a random X within the filled area, or close to start if progress is 0
|
| 335 |
+
targetX = barStartX + random(0, Math.max(5, filledWidth)); // Ensure minimum target width
|
| 336 |
+
// Target Y somewhere vertically within the bar height
|
| 337 |
+
targetY = barStartY + random(0, barRect.height);
|
| 338 |
+
}
|
| 339 |
+
// Fallback if neither is ready (should be rare)
|
| 340 |
+
else {
|
| 341 |
+
targetX = startX; // Stay near start position
|
| 342 |
+
targetY = startY;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
return [
|
| 346 |
+
...prev, // Use the already updated array if status changed, otherwise original prev
|
| 347 |
+
{
|
| 348 |
+
id: uuid(),
|
| 349 |
+
initialX: startX,
|
| 350 |
+
initialY: startY,
|
| 351 |
+
targetPointX: targetX,
|
| 352 |
+
targetPointY: targetY,
|
| 353 |
+
color: color,
|
| 354 |
+
status: status, // Pass current task status
|
| 355 |
+
}
|
| 356 |
+
];
|
| 357 |
+
}
|
| 358 |
+
// If max particles reached, just return the potentially status-updated array
|
| 359 |
+
return prev;
|
| 360 |
+
});
|
| 361 |
+
}, generationRate);
|
| 362 |
+
|
| 363 |
+
return () => clearInterval(intervalId);
|
| 364 |
+
}, [taskId, progress, status, dimensions, barRect, buttonRect, downloadButtonElement, removeParticle, color]);
|
| 365 |
+
|
| 366 |
+
return (
|
| 367 |
+
<div
|
| 368 |
+
ref={containerRef}
|
| 369 |
+
className="absolute inset-0 overflow-hidden z-0 pointer-events-none" // z-0 to be behind content
|
| 370 |
+
>
|
| 371 |
+
{particles.map(p => (
|
| 372 |
+
<Particle
|
| 373 |
+
key={p.id}
|
| 374 |
+
{...p}
|
| 375 |
+
screenHeight={dimensions.height}
|
| 376 |
+
screenWidth={dimensions.width}
|
| 377 |
+
onComplete={removeParticle}
|
| 378 |
+
/>
|
| 379 |
+
))}
|
| 380 |
+
</div>
|
| 381 |
+
);
|
| 382 |
+
};
|
| 383 |
+
|
| 384 |
+
export default ProgressBarParticles;
|
atk-interface/src/components/ScrollingTextBackground.jsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, useEffect, useRef } from 'react';
|
| 2 |
+
import { motion, useMotionValue, useTransform, useAnimationFrame } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
// Function to generate a random number within a range
|
| 5 |
+
const random = (min, max) => Math.random() * (max - min) + min;
|
| 6 |
+
|
| 7 |
+
// Create a single line element
|
| 8 |
+
const Line = ({ width, delay }) => (
|
| 9 |
+
<motion.div
|
| 10 |
+
className="h-1.5 bg-gray-600 rounded"
|
| 11 |
+
style={{ width: `${width}%` }}
|
| 12 |
+
initial={{ opacity: 0 }}
|
| 13 |
+
animate={{ opacity: 0.4 }}
|
| 14 |
+
transition={{ delay, duration: 0.5 }}
|
| 15 |
+
/>
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
// Create the scrolling background component
|
| 19 |
+
const ScrollingTextBackground = ({ duration = 30 }) => {
|
| 20 |
+
const numLines = 50;
|
| 21 |
+
|
| 22 |
+
const lines = useMemo(() => {
|
| 23 |
+
return Array.from({ length: numLines }).map((_, i) => ({
|
| 24 |
+
id: i,
|
| 25 |
+
width: random(20, 80),
|
| 26 |
+
delay: random(0, 1),
|
| 27 |
+
}));
|
| 28 |
+
}, []);
|
| 29 |
+
|
| 30 |
+
// --- Manual Animation Logic ---
|
| 31 |
+
const yProgress = useMotionValue(0); // Tracks progress from 0 to 1
|
| 32 |
+
const yPercentage = useTransform(yProgress, [0, 1], ["0%", "-50%"]); // Maps progress to Y offset
|
| 33 |
+
|
| 34 |
+
const lastFrameTime = useRef(performance.now());
|
| 35 |
+
const animationFrameId = useRef(null); // To store the request ID
|
| 36 |
+
const currentDuration = useRef(duration); // Store duration in a ref
|
| 37 |
+
|
| 38 |
+
// Update the duration ref whenever the prop changes
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
currentDuration.current = duration;
|
| 41 |
+
}, [duration]);
|
| 42 |
+
|
| 43 |
+
useAnimationFrame((timestamp) => {
|
| 44 |
+
const deltaTime = timestamp - lastFrameTime.current;
|
| 45 |
+
|
| 46 |
+
// Guard against potentially huge deltaTime after remount or tab backgrounding.
|
| 47 |
+
// Only update animation if deltaTime is reasonable (e.g., < 1 second).
|
| 48 |
+
// Also handle potential negative deltaTime if clocks adjust.
|
| 49 |
+
if (deltaTime < 0 || deltaTime > 1000) {
|
| 50 |
+
lastFrameTime.current = timestamp; // Reset for the next frame
|
| 51 |
+
return; // Skip animation update for this frame
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
lastFrameTime.current = timestamp; // Update time for the next frame calculation
|
| 55 |
+
|
| 56 |
+
// Calculate progress increment based on current duration
|
| 57 |
+
// duration is in seconds, deltaTime in ms. Convert duration to ms.
|
| 58 |
+
const progressIncrement = deltaTime / (currentDuration.current * 1000);
|
| 59 |
+
|
| 60 |
+
// Update progress, wrapping around using modulo
|
| 61 |
+
const newProgress = (yProgress.get() + progressIncrement) % 1;
|
| 62 |
+
yProgress.set(newProgress);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// --- End Manual Animation Logic ---
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div className="absolute inset-0 overflow-hidden z-0 pointer-events-none">
|
| 69 |
+
<motion.div
|
| 70 |
+
className="w-full"
|
| 71 |
+
style={{ y: yPercentage }} // Apply the transformed y value directly
|
| 72 |
+
// No animate, variants, or initial props needed for y animation here
|
| 73 |
+
>
|
| 74 |
+
{/* First block of lines */}
|
| 75 |
+
<div className="w-full flex flex-col items-center space-y-4">
|
| 76 |
+
{lines.map(line => (
|
| 77 |
+
<Line key={`top-${line.id}`} width={line.width} delay={line.delay} />
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
<br />
|
| 81 |
+
{/* Second block of lines */}
|
| 82 |
+
<div className="w-full flex flex-col items-center space-y-4">
|
| 83 |
+
{lines.map(line => (
|
| 84 |
+
<Line key={`bottom-${line.id}`} width={line.width} delay={line.delay} />
|
| 85 |
+
))}
|
| 86 |
+
</div>
|
| 87 |
+
</motion.div>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
export default ScrollingTextBackground;
|
atk-interface/src/context/FileManagerContext.jsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useState, useContext, useCallback } from 'react';
|
| 2 |
+
import { fetchInputStructure, fetchOutputStructure } from '../api'; // Assuming API functions exist
|
| 3 |
+
|
| 4 |
+
const FileManagerContext = createContext();
|
| 5 |
+
|
| 6 |
+
export const useFileManager = () => useContext(FileManagerContext);
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Adapts API data structure to the structure expected by react-arborist.
|
| 10 |
+
* Sorts directories first, then alphabetically.
|
| 11 |
+
* @param {Array<object>} apiData - Data from the API
|
| 12 |
+
* @returns {Array<object>} Adapted and sorted data
|
| 13 |
+
*/
|
| 14 |
+
const adaptAndSortApiData = (apiData) => {
|
| 15 |
+
if (!Array.isArray(apiData)) return [];
|
| 16 |
+
const adapted = apiData.map(item => ({
|
| 17 |
+
id: item.path,
|
| 18 |
+
name: item.path.split('/').pop() || item.path,
|
| 19 |
+
relativePath: item.path,
|
| 20 |
+
is_dir: item.is_dir,
|
| 21 |
+
children: item.is_dir ? (item.children ? adaptAndSortApiData(item.children) : null) : undefined,
|
| 22 |
+
}));
|
| 23 |
+
// Sort: directories first, then alphabetically
|
| 24 |
+
adapted.sort((a, b) => {
|
| 25 |
+
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
| 26 |
+
return a.name.localeCompare(b.name);
|
| 27 |
+
});
|
| 28 |
+
return adapted;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
export const FileManagerProvider = ({ children }) => {
|
| 33 |
+
// --- Input State ---
|
| 34 |
+
const [inputTreeData, setInputTreeData] = useState(null);
|
| 35 |
+
const [isInputLoading, setIsInputLoading] = useState(false);
|
| 36 |
+
const [inputError, setInputError] = useState(null);
|
| 37 |
+
|
| 38 |
+
// --- Output State ---
|
| 39 |
+
const [outputTreeData, setOutputTreeData] = useState(null);
|
| 40 |
+
const [isOutputLoading, setIsOutputLoading] = useState(false);
|
| 41 |
+
const [outputError, setOutputError] = useState(null);
|
| 42 |
+
|
| 43 |
+
// --- Fetch Input Data (with caching) ---
|
| 44 |
+
const fetchInputData = useCallback(async (forceRefresh = false) => {
|
| 45 |
+
if (inputTreeData && !forceRefresh) {
|
| 46 |
+
// console.log("Using cached input data");
|
| 47 |
+
return; // Use cached data if available and not forcing refresh
|
| 48 |
+
}
|
| 49 |
+
// console.log("Fetching input data...", { forceRefresh });
|
| 50 |
+
setIsInputLoading(true);
|
| 51 |
+
setInputError(null);
|
| 52 |
+
try {
|
| 53 |
+
const structure = await fetchInputStructure();
|
| 54 |
+
const adaptedData = adaptAndSortApiData(structure);
|
| 55 |
+
setInputTreeData(adaptedData);
|
| 56 |
+
} catch (e) {
|
| 57 |
+
console.error("Failed to fetch input structure:", e);
|
| 58 |
+
setInputError(e.message || 'Failed to load input files structure.');
|
| 59 |
+
setInputTreeData([]); // Set empty on error to stop loading indicators
|
| 60 |
+
} finally {
|
| 61 |
+
setIsInputLoading(false);
|
| 62 |
+
}
|
| 63 |
+
}, [inputTreeData]); // Re-run if cached data changes (relevant for forceRefresh logic)
|
| 64 |
+
|
| 65 |
+
// --- Fetch Output Data (with caching) ---
|
| 66 |
+
const fetchOutputData = useCallback(async (forceRefresh = false) => {
|
| 67 |
+
if (outputTreeData && !forceRefresh) {
|
| 68 |
+
// console.log("Using cached output data");
|
| 69 |
+
return; // Use cached data
|
| 70 |
+
}
|
| 71 |
+
// console.log("Fetching output data...", { forceRefresh });
|
| 72 |
+
setIsOutputLoading(true);
|
| 73 |
+
setOutputError(null);
|
| 74 |
+
try {
|
| 75 |
+
const structure = await fetchOutputStructure();
|
| 76 |
+
const adaptedData = adaptAndSortApiData(structure);
|
| 77 |
+
setOutputTreeData(adaptedData);
|
| 78 |
+
} catch (e) {
|
| 79 |
+
console.error("Failed to fetch output structure:", e);
|
| 80 |
+
setOutputError(e.message || 'Failed to load output files structure.');
|
| 81 |
+
setOutputTreeData([]); // Set empty on error
|
| 82 |
+
} finally {
|
| 83 |
+
setIsOutputLoading(false);
|
| 84 |
+
}
|
| 85 |
+
}, [outputTreeData]); // Re-run if cached data changes
|
| 86 |
+
|
| 87 |
+
// --- Refresh Functions ---
|
| 88 |
+
// These simply call the fetch functions with forceRefresh=true
|
| 89 |
+
const refreshInputData = useCallback(() => fetchInputData(true), [fetchInputData]);
|
| 90 |
+
const refreshOutputData = useCallback(() => fetchOutputData(true), [fetchOutputData]);
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
const value = {
|
| 94 |
+
inputTreeData,
|
| 95 |
+
isInputLoading,
|
| 96 |
+
inputError,
|
| 97 |
+
fetchInputData,
|
| 98 |
+
refreshInputData,
|
| 99 |
+
|
| 100 |
+
outputTreeData,
|
| 101 |
+
isOutputLoading,
|
| 102 |
+
outputError,
|
| 103 |
+
fetchOutputData,
|
| 104 |
+
refreshOutputData,
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<FileManagerContext.Provider value={value}>
|
| 109 |
+
{children}
|
| 110 |
+
</FileManagerContext.Provider>
|
| 111 |
+
);
|
| 112 |
+
};
|
atk-interface/src/context/NavigationContext.jsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
// Context to store which nav link should be highlighted (in addition to the active one)
|
| 4 |
+
const NavigationContext = createContext({
|
| 5 |
+
highlightedNavLink: null,
|
| 6 |
+
setHighlightedNavLink: () => {},
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
export const NavigationProvider = ({ children }) => {
|
| 10 |
+
const [highlightedNavLink, setHighlightedNavLink] = useState(null);
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<NavigationContext.Provider value={{ highlightedNavLink, setHighlightedNavLink }}>
|
| 14 |
+
{children}
|
| 15 |
+
</NavigationContext.Provider>
|
| 16 |
+
);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export const useNavigation = () => useContext(NavigationContext);
|
atk-interface/src/context/TaskHistoryContext.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useState, useContext, useCallback, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
const TaskHistoryContext = createContext();
|
| 4 |
+
|
| 5 |
+
export const useTaskHistory = () => useContext(TaskHistoryContext);
|
| 6 |
+
|
| 7 |
+
export const TaskHistoryProvider = ({ children }) => {
|
| 8 |
+
// Initialize state from localStorage if available, otherwise empty array
|
| 9 |
+
const [taskHistory, setTaskHistory] = useState(() => {
|
| 10 |
+
try {
|
| 11 |
+
const storedHistory = localStorage.getItem('atk_taskHistory');
|
| 12 |
+
return storedHistory ? JSON.parse(storedHistory) : [];
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error("Error reading task history from localStorage:", error);
|
| 15 |
+
return [];
|
| 16 |
+
}
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
// Persist history to localStorage whenever it changes
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
try {
|
| 22 |
+
localStorage.setItem('atk_taskHistory', JSON.stringify(taskHistory));
|
| 23 |
+
} catch (error) {
|
| 24 |
+
console.error("Error saving task history to localStorage:", error);
|
| 25 |
+
}
|
| 26 |
+
}, [taskHistory]);
|
| 27 |
+
|
| 28 |
+
const addTaskToHistory = useCallback((taskId) => {
|
| 29 |
+
if (!taskId) return;
|
| 30 |
+
console.log(`[TaskHistoryContext] addTaskToHistory called with taskId: ${taskId}`);
|
| 31 |
+
setTaskHistory(prevHistory => {
|
| 32 |
+
console.log(`[TaskHistoryContext] Previous history:`, prevHistory);
|
| 33 |
+
// Avoid duplicates and add to the beginning (most recent first)
|
| 34 |
+
const newHistory = [taskId, ...prevHistory.filter(id => id !== taskId)];
|
| 35 |
+
// Optional: Limit history size
|
| 36 |
+
// const MAX_HISTORY_SIZE = 20;
|
| 37 |
+
// return newHistory.slice(0, MAX_HISTORY_SIZE);
|
| 38 |
+
console.log(`[TaskHistoryContext] New history:`, newHistory);
|
| 39 |
+
return newHistory;
|
| 40 |
+
});
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const clearTaskHistory = useCallback(() => {
|
| 44 |
+
console.log(`[TaskHistoryContext] clearTaskHistory called.`);
|
| 45 |
+
setTaskHistory([]);
|
| 46 |
+
localStorage.removeItem('atk_taskHistory'); // Clear from storage too
|
| 47 |
+
}, []);
|
| 48 |
+
|
| 49 |
+
const value = {
|
| 50 |
+
taskHistory,
|
| 51 |
+
addTaskToHistory,
|
| 52 |
+
clearTaskHistory,
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<TaskHistoryContext.Provider value={value}>
|
| 57 |
+
{children}
|
| 58 |
+
</TaskHistoryContext.Provider>
|
| 59 |
+
);
|
| 60 |
+
};
|
atk-interface/src/index.css
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
atk-interface/src/main.jsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import { BrowserRouter } from 'react-router-dom'
|
| 4 |
+
import { NavigationProvider } from './context/NavigationContext'
|
| 5 |
+
import './index.css'
|
| 6 |
+
import App from './App.jsx'
|
| 7 |
+
|
| 8 |
+
createRoot(document.getElementById('root')).render(
|
| 9 |
+
<StrictMode>
|
| 10 |
+
<BrowserRouter>
|
| 11 |
+
<NavigationProvider>
|
| 12 |
+
<App />
|
| 13 |
+
</NavigationProvider>
|
| 14 |
+
</BrowserRouter>
|
| 15 |
+
</StrictMode>,
|
| 16 |
+
)
|
atk-interface/src/pages/Chat.jsx
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { FiSend, FiEdit2, FiCopy, FiCheck, FiTrash2, FiSettings, FiRefreshCw } from 'react-icons/fi';
|
| 3 |
+
|
| 4 |
+
function Chat() {
|
| 5 |
+
const [messages, setMessages] = useState([]);
|
| 6 |
+
const [inputMessage, setInputMessage] = useState('');
|
| 7 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 8 |
+
const [serverPort, setServerPort] = useState('8003');
|
| 9 |
+
const [showPortSettings, setShowPortSettings] = useState(false);
|
| 10 |
+
const [serverStatus, setServerStatus] = useState('checking'); // 'checking', 'online', 'offline'
|
| 11 |
+
const [serverType, setServerType] = useState('unknown'); // 'openai', 'rag', 'unknown'
|
| 12 |
+
const [editingMessageId, setEditingMessageId] = useState(null);
|
| 13 |
+
const [copiedMessageId, setCopiedMessageId] = useState(null);
|
| 14 |
+
const [isRefreshingStatus, setIsRefreshingStatus] = useState(false);
|
| 15 |
+
const [isStreaming, setIsStreaming] = useState(true); // New streaming toggle, default true
|
| 16 |
+
const messagesEndRef = useRef(null);
|
| 17 |
+
const inputRef = useRef(null);
|
| 18 |
+
|
| 19 |
+
// Check server status
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
const checkServerStatus = async () => {
|
| 22 |
+
try {
|
| 23 |
+
// Use the health endpoint instead of generate with empty messages
|
| 24 |
+
const baseUrl = window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
|
| 25 |
+
? `http://127.0.0.1:${serverPort}`
|
| 26 |
+
: `${window.location.origin}/llm`;
|
| 27 |
+
const response = await fetch(`${baseUrl}/health`, {
|
| 28 |
+
method: 'GET',
|
| 29 |
+
headers: { 'Content-Type': 'application/json' }
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
if (response.ok) {
|
| 33 |
+
const data = await response.json();
|
| 34 |
+
setServerStatus('online');
|
| 35 |
+
|
| 36 |
+
// Detect server type based on response message
|
| 37 |
+
if (data.message && data.message.includes('RAG')) {
|
| 38 |
+
setServerType('rag');
|
| 39 |
+
} else if (data.message && data.message.includes('OpenAI')) {
|
| 40 |
+
setServerType('openai');
|
| 41 |
+
} else {
|
| 42 |
+
setServerType('unknown');
|
| 43 |
+
}
|
| 44 |
+
} else {
|
| 45 |
+
setServerStatus('offline');
|
| 46 |
+
setServerType('unknown');
|
| 47 |
+
}
|
| 48 |
+
} catch (error) {
|
| 49 |
+
setServerStatus('offline');
|
| 50 |
+
setServerType('unknown');
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// Check immediately when port changes
|
| 55 |
+
checkServerStatus();
|
| 56 |
+
|
| 57 |
+
// Set up interval for periodic checks
|
| 58 |
+
const interval = setInterval(checkServerStatus, 3000); // Check every 3 seconds
|
| 59 |
+
return () => clearInterval(interval);
|
| 60 |
+
}, [serverPort]); // Re-run when serverPort changes
|
| 61 |
+
|
| 62 |
+
// Auto-scroll to bottom when new messages arrive
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 65 |
+
}, [messages]);
|
| 66 |
+
|
| 67 |
+
const handleSendMessage = async () => {
|
| 68 |
+
if (!inputMessage.trim() || isLoading || serverStatus !== 'online') return;
|
| 69 |
+
|
| 70 |
+
const userMessage = { role: 'user', content: inputMessage.trim(), id: Date.now() };
|
| 71 |
+
setMessages(prev => [...prev, userMessage]);
|
| 72 |
+
setInputMessage('');
|
| 73 |
+
setIsLoading(true);
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
// Prepare messages for API (remove id field)
|
| 77 |
+
const apiMessages = messages.concat(userMessage).map(({ role, content }) => ({ role, content }));
|
| 78 |
+
|
| 79 |
+
if (isStreaming) {
|
| 80 |
+
// Handle streaming response
|
| 81 |
+
const assistantMessageId = Date.now() + 1;
|
| 82 |
+
const assistantMessage = {
|
| 83 |
+
role: 'assistant',
|
| 84 |
+
content: '',
|
| 85 |
+
id: assistantMessageId
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
// Add empty assistant message that we'll update
|
| 89 |
+
setMessages(prev => [...prev, assistantMessage]);
|
| 90 |
+
|
| 91 |
+
const baseUrl = window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
|
| 92 |
+
? `http://127.0.0.1:${serverPort}`
|
| 93 |
+
: `${window.location.origin}/llm`;
|
| 94 |
+
const response = await fetch(`${baseUrl}/generate-stream`, {
|
| 95 |
+
method: 'POST',
|
| 96 |
+
headers: { 'Content-Type': 'application/json' },
|
| 97 |
+
body: JSON.stringify({ messages: apiMessages })
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
if (!response.ok) {
|
| 101 |
+
let errorText = `Server responded with ${response.status}`;
|
| 102 |
+
try {
|
| 103 |
+
const errorData = await response.text();
|
| 104 |
+
if (errorData) {
|
| 105 |
+
errorText += `: ${errorData}`;
|
| 106 |
+
}
|
| 107 |
+
} catch (e) {
|
| 108 |
+
// Ignore if we can't read the error body
|
| 109 |
+
}
|
| 110 |
+
throw new Error(errorText);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const reader = response.body.getReader();
|
| 114 |
+
const decoder = new TextDecoder();
|
| 115 |
+
let accumulatedContent = '';
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
while (true) {
|
| 119 |
+
const { done, value } = await reader.read();
|
| 120 |
+
if (done) break;
|
| 121 |
+
|
| 122 |
+
const chunk = decoder.decode(value, { stream: true });
|
| 123 |
+
const lines = chunk.split('\n');
|
| 124 |
+
|
| 125 |
+
for (const line of lines) {
|
| 126 |
+
if (line.startsWith('data: ')) {
|
| 127 |
+
try {
|
| 128 |
+
const data = JSON.parse(line.slice(6));
|
| 129 |
+
if (data.error) {
|
| 130 |
+
throw new Error(data.error);
|
| 131 |
+
}
|
| 132 |
+
if (data.text) {
|
| 133 |
+
accumulatedContent += data.text;
|
| 134 |
+
// Update the assistant message with accumulated content
|
| 135 |
+
setMessages(prev => prev.map(msg =>
|
| 136 |
+
msg.id === assistantMessageId
|
| 137 |
+
? { ...msg, content: accumulatedContent }
|
| 138 |
+
: msg
|
| 139 |
+
));
|
| 140 |
+
}
|
| 141 |
+
if (data.done) {
|
| 142 |
+
break;
|
| 143 |
+
}
|
| 144 |
+
} catch (parseError) {
|
| 145 |
+
console.warn('Failed to parse SSE data:', line);
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
} finally {
|
| 151 |
+
reader.releaseLock();
|
| 152 |
+
}
|
| 153 |
+
} else {
|
| 154 |
+
// Handle non-streaming response (original logic)
|
| 155 |
+
const baseUrl = window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
|
| 156 |
+
? `http://127.0.0.1:${serverPort}`
|
| 157 |
+
: `${window.location.origin}/llm`;
|
| 158 |
+
const response = await fetch(`${baseUrl}/generate`, {
|
| 159 |
+
method: 'POST',
|
| 160 |
+
headers: { 'Content-Type': 'application/json' },
|
| 161 |
+
body: JSON.stringify({ messages: apiMessages })
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
if (!response.ok) {
|
| 165 |
+
let errorText = `Server responded with ${response.status}`;
|
| 166 |
+
try {
|
| 167 |
+
const errorData = await response.text();
|
| 168 |
+
if (errorData) {
|
| 169 |
+
errorText += `: ${errorData}`;
|
| 170 |
+
}
|
| 171 |
+
} catch (e) {
|
| 172 |
+
// Ignore if we can't read the error body
|
| 173 |
+
}
|
| 174 |
+
throw new Error(errorText);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const responseText = await response.text();
|
| 178 |
+
|
| 179 |
+
// Try to parse as JSON first, in case the server wraps the response in quotes
|
| 180 |
+
let finalResponseText;
|
| 181 |
+
try {
|
| 182 |
+
// If the response is JSON-wrapped (e.g., "\"Hello world\""), parse it
|
| 183 |
+
const parsed = JSON.parse(responseText);
|
| 184 |
+
finalResponseText = typeof parsed === 'string' ? parsed : responseText;
|
| 185 |
+
} catch (e) {
|
| 186 |
+
// If it's not JSON, use as-is
|
| 187 |
+
finalResponseText = responseText;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const assistantMessage = {
|
| 191 |
+
role: 'assistant',
|
| 192 |
+
content: finalResponseText,
|
| 193 |
+
id: Date.now() + 1
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
setMessages(prev => [...prev, assistantMessage]);
|
| 197 |
+
}
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error('Error sending message:', error);
|
| 200 |
+
const errorMessage = {
|
| 201 |
+
role: 'assistant',
|
| 202 |
+
content: `Error: ${error.message}. Please check if the server is running on port ${serverPort} and try refreshing the connection.`,
|
| 203 |
+
id: Date.now() + 1,
|
| 204 |
+
isError: true
|
| 205 |
+
};
|
| 206 |
+
setMessages(prev => [...prev, errorMessage]);
|
| 207 |
+
|
| 208 |
+
// Also refresh server status to check if server went offline
|
| 209 |
+
handleManualRefresh();
|
| 210 |
+
} finally {
|
| 211 |
+
setIsLoading(false);
|
| 212 |
+
inputRef.current?.focus();
|
| 213 |
+
}
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
const handleKeyPress = (e) => {
|
| 217 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 218 |
+
e.preventDefault();
|
| 219 |
+
handleSendMessage();
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
const handleCopyMessage = (message) => {
|
| 224 |
+
navigator.clipboard.writeText(message.content);
|
| 225 |
+
setCopiedMessageId(message.id);
|
| 226 |
+
setTimeout(() => setCopiedMessageId(null), 2000);
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
const handleEditMessage = (message) => {
|
| 230 |
+
setEditingMessageId(message.id);
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
const handleSaveEdit = (messageId, newContent) => {
|
| 234 |
+
setMessages(prev => prev.map(msg =>
|
| 235 |
+
msg.id === messageId ? { ...msg, content: newContent } : msg
|
| 236 |
+
));
|
| 237 |
+
setEditingMessageId(null);
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
const handleDeleteMessage = (messageId) => {
|
| 241 |
+
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
const handleClearChat = () => {
|
| 245 |
+
if (window.confirm('Are you sure you want to clear all messages?')) {
|
| 246 |
+
setMessages([]);
|
| 247 |
+
}
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
// Manual refresh function
|
| 251 |
+
const handleManualRefresh = async () => {
|
| 252 |
+
setIsRefreshingStatus(true);
|
| 253 |
+
try {
|
| 254 |
+
const baseUrl = window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'
|
| 255 |
+
? `http://127.0.0.1:${serverPort}`
|
| 256 |
+
: `${window.location.origin}/llm`;
|
| 257 |
+
const response = await fetch(`${baseUrl}/health`, {
|
| 258 |
+
method: 'GET',
|
| 259 |
+
headers: { 'Content-Type': 'application/json' }
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
if (response.ok) {
|
| 263 |
+
const data = await response.json();
|
| 264 |
+
setServerStatus('online');
|
| 265 |
+
|
| 266 |
+
if (data.message && data.message.includes('RAG')) {
|
| 267 |
+
setServerType('rag');
|
| 268 |
+
} else if (data.message && data.message.includes('OpenAI')) {
|
| 269 |
+
setServerType('openai');
|
| 270 |
+
} else {
|
| 271 |
+
setServerType('unknown');
|
| 272 |
+
}
|
| 273 |
+
} else {
|
| 274 |
+
setServerStatus('offline');
|
| 275 |
+
setServerType('unknown');
|
| 276 |
+
}
|
| 277 |
+
} catch (error) {
|
| 278 |
+
setServerStatus('offline');
|
| 279 |
+
setServerType('unknown');
|
| 280 |
+
} finally {
|
| 281 |
+
setIsRefreshingStatus(false);
|
| 282 |
+
}
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
const MessageComponent = ({ message }) => {
|
| 286 |
+
const [editContent, setEditContent] = useState(message.content);
|
| 287 |
+
const isEditing = editingMessageId === message.id;
|
| 288 |
+
const isCopied = copiedMessageId === message.id;
|
| 289 |
+
|
| 290 |
+
return (
|
| 291 |
+
<div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} mb-4`}>
|
| 292 |
+
<div className={`max-w-[70%] rounded-lg p-4 ${
|
| 293 |
+
message.role === 'user'
|
| 294 |
+
? 'bg-blue-600 text-white'
|
| 295 |
+
: message.isError
|
| 296 |
+
? 'bg-red-800 text-red-100'
|
| 297 |
+
: 'bg-gray-700 text-gray-100'
|
| 298 |
+
}`}>
|
| 299 |
+
<div className="flex items-start justify-between gap-2 mb-1">
|
| 300 |
+
<span className="text-xs opacity-70 font-semibold">
|
| 301 |
+
{message.role === 'user' ? 'You' : 'Assistant'}
|
| 302 |
+
</span>
|
| 303 |
+
<div className="flex gap-1">
|
| 304 |
+
<button
|
| 305 |
+
onClick={() => handleCopyMessage(message)}
|
| 306 |
+
className="p-1 hover:bg-white/10 rounded transition-colors"
|
| 307 |
+
title="Copy message"
|
| 308 |
+
>
|
| 309 |
+
{isCopied ? <FiCheck size={14} /> : <FiCopy size={14} />}
|
| 310 |
+
</button>
|
| 311 |
+
<button
|
| 312 |
+
onClick={() => handleEditMessage(message)}
|
| 313 |
+
className="p-1 hover:bg-white/10 rounded transition-colors"
|
| 314 |
+
title="Edit message"
|
| 315 |
+
>
|
| 316 |
+
<FiEdit2 size={14} />
|
| 317 |
+
</button>
|
| 318 |
+
<button
|
| 319 |
+
onClick={() => handleDeleteMessage(message.id)}
|
| 320 |
+
className="p-1 hover:bg-white/10 rounded transition-colors"
|
| 321 |
+
title="Delete message"
|
| 322 |
+
>
|
| 323 |
+
<FiTrash2 size={14} />
|
| 324 |
+
</button>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
{isEditing ? (
|
| 329 |
+
<div className="mt-2">
|
| 330 |
+
<textarea
|
| 331 |
+
value={editContent}
|
| 332 |
+
onChange={(e) => setEditContent(e.target.value)}
|
| 333 |
+
className="w-full p-2 bg-black/20 rounded text-sm resize-none"
|
| 334 |
+
rows={4}
|
| 335 |
+
autoFocus
|
| 336 |
+
/>
|
| 337 |
+
<div className="flex gap-2 mt-2">
|
| 338 |
+
<button
|
| 339 |
+
onClick={() => handleSaveEdit(message.id, editContent)}
|
| 340 |
+
className="px-3 py-1 bg-green-600 hover:bg-green-700 rounded text-xs"
|
| 341 |
+
>
|
| 342 |
+
Save
|
| 343 |
+
</button>
|
| 344 |
+
<button
|
| 345 |
+
onClick={() => {
|
| 346 |
+
setEditingMessageId(null);
|
| 347 |
+
setEditContent(message.content);
|
| 348 |
+
}}
|
| 349 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 rounded text-xs"
|
| 350 |
+
>
|
| 351 |
+
Cancel
|
| 352 |
+
</button>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
) : (
|
| 356 |
+
<div className="whitespace-pre-wrap break-words text-sm">
|
| 357 |
+
{message.content}
|
| 358 |
+
</div>
|
| 359 |
+
)}
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
);
|
| 363 |
+
};
|
| 364 |
+
|
| 365 |
+
return (
|
| 366 |
+
<div className="flex flex-col h-full bg-gray-900">
|
| 367 |
+
{/* Header */}
|
| 368 |
+
<div className="bg-gray-800 p-4 border-b border-gray-700">
|
| 369 |
+
<div className="flex items-center justify-between">
|
| 370 |
+
<div className="flex items-center gap-4">
|
| 371 |
+
<h1 className="text-xl font-bold text-white">Chat Interface</h1>
|
| 372 |
+
<div className="flex items-center gap-2">
|
| 373 |
+
<div className={`w-2 h-2 rounded-full ${
|
| 374 |
+
serverStatus === 'online' ? 'bg-green-500' :
|
| 375 |
+
serverStatus === 'offline' ? 'bg-red-500' :
|
| 376 |
+
'bg-yellow-500'
|
| 377 |
+
}`} />
|
| 378 |
+
<span className="text-sm text-gray-400">
|
| 379 |
+
{serverStatus === 'online'
|
| 380 |
+
? `Server Online (${serverType === 'rag' ? 'RAG-Enabled' : serverType === 'openai' ? 'Standard' : 'Unknown Type'})`
|
| 381 |
+
: serverStatus === 'offline'
|
| 382 |
+
? 'Server Offline'
|
| 383 |
+
: 'Checking...'}
|
| 384 |
+
{isRefreshingStatus && serverStatus === 'online' && (
|
| 385 |
+
<span className="ml-1 text-xs opacity-60">(refreshing)</span>
|
| 386 |
+
)}
|
| 387 |
+
</span>
|
| 388 |
+
{serverStatus === 'online' && (
|
| 389 |
+
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">
|
| 390 |
+
Port {serverPort}
|
| 391 |
+
</span>
|
| 392 |
+
)}
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
<div className="flex items-center gap-2">
|
| 397 |
+
<button
|
| 398 |
+
onClick={handleManualRefresh}
|
| 399 |
+
disabled={isRefreshingStatus}
|
| 400 |
+
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
| 401 |
+
title="Refresh server status"
|
| 402 |
+
>
|
| 403 |
+
<FiRefreshCw className={isRefreshingStatus ? 'animate-spin' : ''} />
|
| 404 |
+
</button>
|
| 405 |
+
<button
|
| 406 |
+
onClick={() => setShowPortSettings(!showPortSettings)}
|
| 407 |
+
className="p-2 hover:bg-gray-700 rounded transition-colors"
|
| 408 |
+
title="Server settings"
|
| 409 |
+
>
|
| 410 |
+
<FiSettings />
|
| 411 |
+
</button>
|
| 412 |
+
<button
|
| 413 |
+
onClick={handleClearChat}
|
| 414 |
+
className="px-3 py-1 bg-red-600 hover:bg-red-700 rounded text-sm"
|
| 415 |
+
disabled={messages.length === 0}
|
| 416 |
+
>
|
| 417 |
+
Clear Chat
|
| 418 |
+
</button>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
|
| 422 |
+
{/* Port Settings */}
|
| 423 |
+
{showPortSettings && (
|
| 424 |
+
<div className="mt-4 pt-4 border-t border-gray-700">
|
| 425 |
+
<div className="flex items-center gap-4 mb-3">
|
| 426 |
+
<div className="flex items-center gap-2">
|
| 427 |
+
<label className="text-sm text-gray-400">Server Port:</label>
|
| 428 |
+
<input
|
| 429 |
+
type="text"
|
| 430 |
+
value={serverPort}
|
| 431 |
+
onChange={(e) => setServerPort(e.target.value)}
|
| 432 |
+
className="px-2 py-1 bg-gray-700 rounded text-sm w-20"
|
| 433 |
+
placeholder="8003"
|
| 434 |
+
/>
|
| 435 |
+
<span className="text-xs text-gray-500">
|
| 436 |
+
Default: 8003 (Change if server is running on a different port)
|
| 437 |
+
</span>
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
<div className="flex items-center gap-2">
|
| 441 |
+
<label className="text-sm text-gray-400">Streaming:</label>
|
| 442 |
+
<button
|
| 443 |
+
onClick={() => setIsStreaming(!isStreaming)}
|
| 444 |
+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
| 445 |
+
isStreaming ? 'bg-blue-600' : 'bg-gray-600'
|
| 446 |
+
}`}
|
| 447 |
+
>
|
| 448 |
+
<span
|
| 449 |
+
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
| 450 |
+
isStreaming ? 'translate-x-6' : 'translate-x-1'
|
| 451 |
+
}`}
|
| 452 |
+
/>
|
| 453 |
+
</button>
|
| 454 |
+
<span className="text-xs text-gray-500">
|
| 455 |
+
{isStreaming ? 'On' : 'Off'} - Stream responses in real-time
|
| 456 |
+
</span>
|
| 457 |
+
</div>
|
| 458 |
+
</div>
|
| 459 |
+
)}
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
{/* Messages Area */}
|
| 463 |
+
<div className="flex-grow overflow-y-auto p-4">
|
| 464 |
+
{messages.length === 0 ? (
|
| 465 |
+
<div className="flex items-center justify-center h-full">
|
| 466 |
+
<div className="text-center text-gray-500 max-w-2xl px-4">
|
| 467 |
+
{serverStatus === 'online' ? (
|
| 468 |
+
<>
|
| 469 |
+
<p className="text-lg mb-2">No messages yet</p>
|
| 470 |
+
<p className="text-sm">
|
| 471 |
+
Start a conversation by typing a message below
|
| 472 |
+
</p>
|
| 473 |
+
</>
|
| 474 |
+
) : (
|
| 475 |
+
<>
|
| 476 |
+
<p className="text-lg mb-4 text-gray-400">Welcome to the Chat Interface</p>
|
| 477 |
+
<div className="text-sm leading-relaxed space-y-3">
|
| 478 |
+
<p>
|
| 479 |
+
This page is where you can interact with models that you train using Augmentoolkit.
|
| 480 |
+
Start an LLM server with one of the utility pipelines (<span className="font-mono bg-gray-800 px-2 py-1 rounded">basic-server</span> or <span className="font-mono bg-gray-800 px-2 py-1 rounded">rag-server</span>, in <span className="font-mono bg-gray-800 px-2 py-1 rounded">generation/utilities</span>)
|
| 481 |
+
and when it reaches 100% completion, this page will activate and let you chat with your model.
|
| 482 |
+
</p>
|
| 483 |
+
<p className="text-xs text-gray-600">
|
| 484 |
+
<strong>Pro tip:</strong> if you want to use your custom model in production, the code for these pipelines also provides a great reference. Check it out!
|
| 485 |
+
</p>
|
| 486 |
+
</div>
|
| 487 |
+
</>
|
| 488 |
+
)}
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
) : (
|
| 492 |
+
<div>
|
| 493 |
+
{messages.map(message => (
|
| 494 |
+
<MessageComponent key={message.id} message={message} />
|
| 495 |
+
))}
|
| 496 |
+
</div>
|
| 497 |
+
)}
|
| 498 |
+
{isLoading && (
|
| 499 |
+
<div className="flex justify-start mb-4">
|
| 500 |
+
<div className="bg-gray-700 rounded-lg p-4 max-w-[70%]">
|
| 501 |
+
<div className="flex items-center gap-2">
|
| 502 |
+
<div className="animate-pulse flex gap-1">
|
| 503 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 504 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 505 |
+
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 506 |
+
</div>
|
| 507 |
+
<span className="text-sm text-gray-400">Assistant is typing...</span>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
)}
|
| 512 |
+
<div ref={messagesEndRef} />
|
| 513 |
+
</div>
|
| 514 |
+
|
| 515 |
+
{/* Input Area */}
|
| 516 |
+
<div className="border-t border-gray-700 p-4 bg-gray-800">
|
| 517 |
+
<div className="flex gap-2">
|
| 518 |
+
<textarea
|
| 519 |
+
ref={inputRef}
|
| 520 |
+
value={inputMessage}
|
| 521 |
+
onChange={(e) => setInputMessage(e.target.value)}
|
| 522 |
+
onKeyPress={handleKeyPress}
|
| 523 |
+
placeholder={
|
| 524 |
+
serverStatus === 'online'
|
| 525 |
+
? "Type your message here... (Press Enter to send, Shift+Enter for new line)"
|
| 526 |
+
: "Waiting for server connection..."
|
| 527 |
+
}
|
| 528 |
+
className="flex-grow p-3 bg-gray-700 text-white rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 529 |
+
rows={3}
|
| 530 |
+
disabled={serverStatus !== 'online'}
|
| 531 |
+
/>
|
| 532 |
+
<button
|
| 533 |
+
onClick={handleSendMessage}
|
| 534 |
+
disabled={!inputMessage.trim() || isLoading || serverStatus !== 'online'}
|
| 535 |
+
className={`px-6 py-3 rounded-lg font-semibold transition-colors flex items-center gap-2 ${
|
| 536 |
+
!inputMessage.trim() || isLoading || serverStatus !== 'online'
|
| 537 |
+
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
| 538 |
+
: 'bg-blue-600 text-white hover:bg-blue-700'
|
| 539 |
+
}`}
|
| 540 |
+
>
|
| 541 |
+
<FiSend />
|
| 542 |
+
Send
|
| 543 |
+
</button>
|
| 544 |
+
</div>
|
| 545 |
+
</div>
|
| 546 |
+
</div>
|
| 547 |
+
);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
export default Chat;
|
atk-interface/src/pages/ConfigEditor.jsx
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ------------------------------------------------------------------ */
|
| 2 |
+
/* Imports + stable placeholder regex */
|
| 3 |
+
/* ------------------------------------------------------------------ */
|
| 4 |
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
| 5 |
+
import { useParams, useNavigate, Link } from 'react-router-dom';
|
| 6 |
+
import Editor from '@monaco-editor/react';
|
| 7 |
+
import yaml from 'js-yaml';
|
| 8 |
+
import { FiChevronLeft, FiX, FiLoader } from 'react-icons/fi';
|
| 9 |
+
import Modal from '../components/Modal';
|
| 10 |
+
import EnhancedButton from '../components/EnhancedButton';
|
| 11 |
+
import {
|
| 12 |
+
fetchConfigContent,
|
| 13 |
+
saveConfigContent,
|
| 14 |
+
fetchAvailablePipelines,
|
| 15 |
+
runPipeline,
|
| 16 |
+
} from '../api';
|
| 17 |
+
|
| 18 |
+
/* 1️⃣ Define the regex **outside** the component so it never changes */
|
| 19 |
+
const PLACEHOLDER_REGEX = /!!PLACEHOLDER!!/;
|
| 20 |
+
const ATTENTION_REGEX = /!!ATTENTION!!/;
|
| 21 |
+
|
| 22 |
+
/* ------------------------------------------------------------------ */
|
| 23 |
+
/* ConfigEditor */
|
| 24 |
+
/* ------------------------------------------------------------------ */
|
| 25 |
+
function ConfigEditor() {
|
| 26 |
+
/* ---------- routing & refs ---------- */
|
| 27 |
+
const { configId: encodedConfigId } = useParams();
|
| 28 |
+
const configId = decodeURIComponent(encodedConfigId || '');
|
| 29 |
+
const navigate = useNavigate();
|
| 30 |
+
const editorRef = useRef(null);
|
| 31 |
+
const monacoRef = useRef(null);
|
| 32 |
+
const placeholderDecorationsRef = useRef([]);
|
| 33 |
+
const attentionDecorationsRef = useRef([]);
|
| 34 |
+
|
| 35 |
+
/* ---------- editor state ---------- */
|
| 36 |
+
const [originalContent, setOriginalContent] = useState('');
|
| 37 |
+
const [currentContent, setCurrentContent] = useState('');
|
| 38 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 39 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 40 |
+
const [editorError, setEditorError] = useState(null);
|
| 41 |
+
const [saveSuccess, setSaveSuccess] = useState(false);
|
| 42 |
+
const isDirty = originalContent !== currentContent;
|
| 43 |
+
|
| 44 |
+
/* ---------- placeholder/attention detection ---------- */
|
| 45 |
+
const [placeholderLines, setPlaceholderLines] = useState([]); // line numbers (1-based)
|
| 46 |
+
const [attentionLines, setAttentionLines] = useState([]); // line numbers (1-based)
|
| 47 |
+
|
| 48 |
+
/** Regex that matches: `key: !!PLACEHOLDER!!`, `key: '!!PLACEHOLDER!!'`, `key: "!!PLACEHOLDER!!"` */
|
| 49 |
+
// const PLACEHOLDER_REGEX = /:\s*['"]?!!PLACEHOLDER!!['"]?\s*$/; // Defined outside now
|
| 50 |
+
|
| 51 |
+
/* ---------- run & modal state ---------- */
|
| 52 |
+
const [isRunModalOpen, setIsRunModalOpen] = useState(false);
|
| 53 |
+
const [availablePipelines, setAvailablePipelines] = useState([]);
|
| 54 |
+
const [isFetchingPipelines, setIsFetchingPipelines] = useState(false);
|
| 55 |
+
const [modalNodePath, setModalNodePath] = useState('');
|
| 56 |
+
const [suggestions, setSuggestions] = useState([]);
|
| 57 |
+
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
| 58 |
+
const [runError, setRunError] = useState(null);
|
| 59 |
+
const [isSubmittingRun, setIsSubmittingRun] = useState(false);
|
| 60 |
+
const nodePathInputRef = useRef(null);
|
| 61 |
+
|
| 62 |
+
/* ==================================================================
|
| 63 |
+
Helpers -- placeholder scanning & Monaco decorations
|
| 64 |
+
================================================================== */
|
| 65 |
+
const updateLineMarkers = useCallback((content) => {
|
| 66 |
+
if (!editorRef.current || !monacoRef.current) return;
|
| 67 |
+
const monaco = monacoRef.current;
|
| 68 |
+
const editor = editorRef.current;
|
| 69 |
+
|
| 70 |
+
const newPlaceholderLines = [];
|
| 71 |
+
const newAttentionLines = [];
|
| 72 |
+
const placeholderDecorations = [];
|
| 73 |
+
const attentionDecorations = [];
|
| 74 |
+
|
| 75 |
+
content.split('\n').forEach((line, idx) => {
|
| 76 |
+
const lineNo = idx + 1;
|
| 77 |
+
if (PLACEHOLDER_REGEX.test(line)) {
|
| 78 |
+
newPlaceholderLines.push(lineNo);
|
| 79 |
+
placeholderDecorations.push({
|
| 80 |
+
range: new monaco.Range(lineNo, 1, lineNo, 1),
|
| 81 |
+
options: {
|
| 82 |
+
isWholeLine: true,
|
| 83 |
+
className: 'placeholder-line-highlight',
|
| 84 |
+
hoverMessage: { value: 'Replace !!PLACEHOLDER!! before running' },
|
| 85 |
+
},
|
| 86 |
+
});
|
| 87 |
+
} else if (ATTENTION_REGEX.test(line)) {
|
| 88 |
+
newAttentionLines.push(lineNo);
|
| 89 |
+
attentionDecorations.push({
|
| 90 |
+
range: new monaco.Range(lineNo, 1, lineNo, 1),
|
| 91 |
+
options: {
|
| 92 |
+
isWholeLine: true,
|
| 93 |
+
className: 'attention-line-highlight',
|
| 94 |
+
hoverMessage: { value: 'Note: !!ATTENTION!! marker present' },
|
| 95 |
+
},
|
| 96 |
+
});
|
| 97 |
+
}
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
setPlaceholderLines(newPlaceholderLines);
|
| 101 |
+
setAttentionLines(newAttentionLines);
|
| 102 |
+
|
| 103 |
+
// Update decorations using deltaDecorations
|
| 104 |
+
placeholderDecorationsRef.current = editor.deltaDecorations(
|
| 105 |
+
placeholderDecorationsRef.current,
|
| 106 |
+
placeholderDecorations
|
| 107 |
+
);
|
| 108 |
+
attentionDecorationsRef.current = editor.deltaDecorations(
|
| 109 |
+
attentionDecorationsRef.current,
|
| 110 |
+
attentionDecorations
|
| 111 |
+
);
|
| 112 |
+
}, []); // Removed dependency on editorRef/monacoRef as they are refs
|
| 113 |
+
|
| 114 |
+
/* ==================================================================
|
| 115 |
+
Initial load
|
| 116 |
+
================================================================== */
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
if (!configId) return;
|
| 119 |
+
|
| 120 |
+
(async () => {
|
| 121 |
+
try {
|
| 122 |
+
setIsLoading(true);
|
| 123 |
+
const text = await fetchConfigContent(configId);
|
| 124 |
+
setOriginalContent(text);
|
| 125 |
+
setCurrentContent(text);
|
| 126 |
+
setTimeout(() => updateLineMarkers(text), 0);
|
| 127 |
+
} catch (e) {
|
| 128 |
+
console.error(e);
|
| 129 |
+
setEditorError(e.message || 'Failed to load configuration content.');
|
| 130 |
+
} finally {
|
| 131 |
+
setIsLoading(false);
|
| 132 |
+
}
|
| 133 |
+
})();
|
| 134 |
+
}, [configId]);
|
| 135 |
+
|
| 136 |
+
/* ==================================================================
|
| 137 |
+
Editor change handler
|
| 138 |
+
================================================================== */
|
| 139 |
+
const handleEditorChange = (value) => {
|
| 140 |
+
const newVal = value || '';
|
| 141 |
+
setCurrentContent(newVal);
|
| 142 |
+
if (saveSuccess) setSaveSuccess(false);
|
| 143 |
+
if (editorError) setEditorError(null);
|
| 144 |
+
if (runError) setRunError(null);
|
| 145 |
+
updateLineMarkers(newVal);
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
/* ==================================================================
|
| 149 |
+
Save / Reset
|
| 150 |
+
================================================================== */
|
| 151 |
+
const handleSave = useCallback(async () => {
|
| 152 |
+
if (!isDirty || isSaving || !configId) return;
|
| 153 |
+
try {
|
| 154 |
+
setIsSaving(true);
|
| 155 |
+
await saveConfigContent(configId, currentContent);
|
| 156 |
+
setOriginalContent(currentContent);
|
| 157 |
+
setSaveSuccess(true);
|
| 158 |
+
setTimeout(() => setSaveSuccess(false), 3000);
|
| 159 |
+
} catch (e) {
|
| 160 |
+
console.error(e);
|
| 161 |
+
setEditorError(e.message || 'Failed to save configuration.');
|
| 162 |
+
} finally {
|
| 163 |
+
setIsSaving(false);
|
| 164 |
+
}
|
| 165 |
+
}, [configId, currentContent, isDirty, isSaving]);
|
| 166 |
+
|
| 167 |
+
const handleReset = () => {
|
| 168 |
+
if (!isDirty) return;
|
| 169 |
+
setCurrentContent(originalContent);
|
| 170 |
+
setEditorError(null);
|
| 171 |
+
setSaveSuccess(false);
|
| 172 |
+
setRunError(null);
|
| 173 |
+
updateLineMarkers(originalContent);
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
/* Ctrl/Cmd-S shortcut */
|
| 177 |
+
useEffect(() => {
|
| 178 |
+
const keydown = (e) => {
|
| 179 |
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
| 180 |
+
e.preventDefault();
|
| 181 |
+
if (isDirty) handleSave();
|
| 182 |
+
}
|
| 183 |
+
};
|
| 184 |
+
window.addEventListener('keydown', keydown);
|
| 185 |
+
return () => window.removeEventListener('keydown', keydown);
|
| 186 |
+
}, [handleSave, isDirty]);
|
| 187 |
+
|
| 188 |
+
/* ==================================================================
|
| 189 |
+
Monaco mount
|
| 190 |
+
================================================================== */
|
| 191 |
+
function handleEditorDidMount(editor, monaco) {
|
| 192 |
+
editorRef.current = editor;
|
| 193 |
+
monacoRef.current = monaco;
|
| 194 |
+
updateLineMarkers(currentContent); // Use the correct function name
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* ==================================================================
|
| 198 |
+
Run button & modal logic
|
| 199 |
+
================================================================== */
|
| 200 |
+
const sendRunRequest = useCallback(
|
| 201 |
+
async (nodePath, parameters) => {
|
| 202 |
+
if (isSubmittingRun) return;
|
| 203 |
+
try {
|
| 204 |
+
console.log('Sending run request:', { nodePath, parameters });
|
| 205 |
+
setIsSubmittingRun(true);
|
| 206 |
+
const result = await runPipeline(nodePath, parameters);
|
| 207 |
+
setIsRunModalOpen(false);
|
| 208 |
+
navigate(`/monitor?pipeline_id=${result.pipeline_id}`);
|
| 209 |
+
} catch (e) {
|
| 210 |
+
console.error('Error during run request:', e);
|
| 211 |
+
setRunError(
|
| 212 |
+
e.message || 'An unknown error occurred while trying to run the pipeline.'
|
| 213 |
+
);
|
| 214 |
+
} finally {
|
| 215 |
+
setIsSubmittingRun(false);
|
| 216 |
+
}
|
| 217 |
+
},
|
| 218 |
+
[navigate, isSubmittingRun]
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
const handleRunClick = async () => {
|
| 222 |
+
console.log('handleRunClick invoked!');
|
| 223 |
+
setRunError(null);
|
| 224 |
+
|
| 225 |
+
/* Warn but still allow running if placeholders exist */
|
| 226 |
+
if (placeholderLines.length > 0) {
|
| 227 |
+
console.warn(
|
| 228 |
+
'Running pipeline while !!PLACEHOLDER!! values are still present.'
|
| 229 |
+
);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
let parsedConfig = {};
|
| 233 |
+
try {
|
| 234 |
+
parsedConfig = yaml.load(currentContent) || {};
|
| 235 |
+
if (typeof parsedConfig !== 'object') {
|
| 236 |
+
throw new Error('Invalid YAML: Config must be an object.');
|
| 237 |
+
}
|
| 238 |
+
} catch (e) {
|
| 239 |
+
console.error('YAML Parse Error in handleRunClick:', e);
|
| 240 |
+
setRunError(`YAML Parse Error: ${e.message}`);
|
| 241 |
+
return;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
const nodePathFromConfig = parsedConfig.pipeline;
|
| 245 |
+
if (nodePathFromConfig && typeof nodePathFromConfig === 'string') {
|
| 246 |
+
if (isDirty) {
|
| 247 |
+
console.warn(
|
| 248 |
+
'Running pipeline with unsaved changes in the editor.'
|
| 249 |
+
);
|
| 250 |
+
}
|
| 251 |
+
sendRunRequest(nodePathFromConfig, parsedConfig);
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/* Fallback modal flow */
|
| 256 |
+
console.log("Fallback: Attempting to open modal");
|
| 257 |
+
setModalNodePath('');
|
| 258 |
+
setSuggestions([]);
|
| 259 |
+
setSelectedSuggestionIndex(-1);
|
| 260 |
+
setIsRunModalOpen(true);
|
| 261 |
+
console.log("Fallback: Set isRunModalOpen to true");
|
| 262 |
+
if (availablePipelines.length === 0) fetchAvailablePipelinesApiCall();
|
| 263 |
+
setTimeout(() => nodePathInputRef.current?.focus(), 100);
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const fetchAvailablePipelinesApiCall = async () => {
|
| 267 |
+
if (isFetchingPipelines) return;
|
| 268 |
+
try {
|
| 269 |
+
setIsFetchingPipelines(true);
|
| 270 |
+
const data = await fetchAvailablePipelines();
|
| 271 |
+
setAvailablePipelines(Array.isArray(data) ? data : []);
|
| 272 |
+
} catch (e) {
|
| 273 |
+
console.error(e);
|
| 274 |
+
setRunError('Could not load pipeline suggestions.');
|
| 275 |
+
} finally {
|
| 276 |
+
setIsFetchingPipelines(false);
|
| 277 |
+
}
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
// --- Modal Interaction Logic ---
|
| 281 |
+
const handleNodePathChange = (e) => {
|
| 282 |
+
const value = e.target.value;
|
| 283 |
+
setModalNodePath(value);
|
| 284 |
+
setSelectedSuggestionIndex(-1);
|
| 285 |
+
if (value && availablePipelines.length > 0) {
|
| 286 |
+
const lowerValue = value.toLowerCase();
|
| 287 |
+
setSuggestions(availablePipelines.filter(p => p.toLowerCase().includes(lowerValue)).slice(0, 5));
|
| 288 |
+
} else {
|
| 289 |
+
setSuggestions([]);
|
| 290 |
+
}
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
const handleSuggestionClick = (suggestion) => {
|
| 294 |
+
setModalNodePath(suggestion);
|
| 295 |
+
setSuggestions([]);
|
| 296 |
+
setSelectedSuggestionIndex(-1);
|
| 297 |
+
nodePathInputRef.current?.focus();
|
| 298 |
+
};
|
| 299 |
+
|
| 300 |
+
const handleModalKeyDown = (e) => {
|
| 301 |
+
if (suggestions.length === 0) return;
|
| 302 |
+
if (e.key === 'ArrowDown') {
|
| 303 |
+
e.preventDefault();
|
| 304 |
+
setSelectedSuggestionIndex(prev => (prev + 1) % suggestions.length);
|
| 305 |
+
} else if (e.key === 'ArrowUp') {
|
| 306 |
+
e.preventDefault();
|
| 307 |
+
setSelectedSuggestionIndex(prev => (prev - 1 + suggestions.length) % suggestions.length);
|
| 308 |
+
} else if (e.key === 'Enter' && selectedSuggestionIndex !== -1) {
|
| 309 |
+
e.preventDefault();
|
| 310 |
+
handleSuggestionClick(suggestions[selectedSuggestionIndex]);
|
| 311 |
+
} else if (e.key === 'Escape') {
|
| 312 |
+
setSuggestions([]);
|
| 313 |
+
setSelectedSuggestionIndex(-1);
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
const handleModalSubmit = () => {
|
| 318 |
+
if (!modalNodePath.trim()) {
|
| 319 |
+
setRunError("Pipeline node path cannot be empty.");
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
let parsedConfig = {};
|
| 323 |
+
try {
|
| 324 |
+
parsedConfig = yaml.load(currentContent);
|
| 325 |
+
if (typeof parsedConfig !== 'object' || parsedConfig === null) throw new Error();
|
| 326 |
+
} catch (e) {
|
| 327 |
+
setRunError('Internal Error: Invalid YAML content detected before sending run request.')
|
| 328 |
+
return;
|
| 329 |
+
}
|
| 330 |
+
sendRunRequest(modalNodePath.trim(), parsedConfig);
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
/* ==================================================================
|
| 334 |
+
UI helpers
|
| 335 |
+
================================================================== */
|
| 336 |
+
const hasPlaceholders = placeholderLines.length > 0;
|
| 337 |
+
const hasAttention = attentionLines.length > 0;
|
| 338 |
+
const buttonBase =
|
| 339 |
+
'px-4 py-2 rounded font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm h-9 flex items-center justify-center';
|
| 340 |
+
const resetButtonClass = `${buttonBase} ${
|
| 341 |
+
isDirty
|
| 342 |
+
? 'bg-red-600 hover:bg-red-500 text-white'
|
| 343 |
+
: 'bg-gray-600 text-gray-400'
|
| 344 |
+
}`;
|
| 345 |
+
const saveButtonClass = `${buttonBase} ${
|
| 346 |
+
isDirty
|
| 347 |
+
? 'bg-blue-600 hover:bg-blue-500 text-white'
|
| 348 |
+
: 'bg-gray-600 text-gray-400'
|
| 349 |
+
}`;
|
| 350 |
+
const backButtonClass = 'text-gray-300 hover:text-white hover:bg-gray-700';
|
| 351 |
+
|
| 352 |
+
/* ==================================================================
|
| 353 |
+
Render
|
| 354 |
+
================================================================== */
|
| 355 |
+
console.log('Render - Button states:', { isSaving, isLoading, isSubmittingRun });
|
| 356 |
+
const calculatedDisabled = isSaving || isLoading || isSubmittingRun;
|
| 357 |
+
console.log('Render - Calculated disabled:', calculatedDisabled);
|
| 358 |
+
console.log('Render - isDirty:', isDirty, 'hasPlaceholders:', hasPlaceholders);
|
| 359 |
+
console.log('Render - hasAttention:', hasAttention); // Added log for attention state
|
| 360 |
+
|
| 361 |
+
return (
|
| 362 |
+
<div className='flex flex-col h-full p-4'>
|
| 363 |
+
{/* ---------------- Header ---------------- */}
|
| 364 |
+
<div className='flex justify-between items-center mb-4 gap-4'>
|
| 365 |
+
{/* Title + Back */}
|
| 366 |
+
<div className='flex items-center gap-3 min-w-0'>
|
| 367 |
+
<Link
|
| 368 |
+
to='/configs'
|
| 369 |
+
className={`${buttonBase} ${backButtonClass} pl-2 pr-3`}
|
| 370 |
+
>
|
| 371 |
+
<FiChevronLeft className='w-5 h-5 mr-1' /> Back
|
| 372 |
+
</Link>
|
| 373 |
+
<div className='min-w-0'>
|
| 374 |
+
<h1 className='text-xl lg:text-2xl font-bold text-gray-100 truncate'>
|
| 375 |
+
Config Editor
|
| 376 |
+
</h1>
|
| 377 |
+
<p
|
| 378 |
+
className='text-gray-400 text-xs lg:text-sm mt-1 truncate'
|
| 379 |
+
title={configId}
|
| 380 |
+
>
|
| 381 |
+
Editing:{' '}
|
| 382 |
+
<code className='bg-gray-700 px-1 rounded'>{configId}</code>
|
| 383 |
+
</p>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
|
| 387 |
+
{/* Action buttons + placeholder/attention warning */}
|
| 388 |
+
<div className='flex flex-col items-end gap-1 flex-shrink-0'>
|
| 389 |
+
{/* Row for messages */}
|
| 390 |
+
<div className="flex items-center justify-end gap-3 text-xs sm:text-sm h-5">
|
| 391 |
+
{hasPlaceholders && (
|
| 392 |
+
<span
|
| 393 |
+
className='text-amber-300 italic whitespace-nowrap'
|
| 394 |
+
title='Replace all !!PLACEHOLDER!! values before final run'
|
| 395 |
+
>
|
| 396 |
+
⚠️ Placeholders on line{placeholderLines.length > 1 ? 's' : ''}:{' '}
|
| 397 |
+
{placeholderLines.join(', ')}
|
| 398 |
+
</span>
|
| 399 |
+
)}
|
| 400 |
+
{hasAttention && (
|
| 401 |
+
<span
|
| 402 |
+
className='text-cyan-300 italic whitespace-nowrap'
|
| 403 |
+
title='Note: !!ATTENTION!! markers present on these lines'
|
| 404 |
+
>
|
| 405 |
+
ℹ️ Attention on line{attentionLines.length > 1 ? 's' : ''}:{' '}
|
| 406 |
+
{attentionLines.join(', ')}
|
| 407 |
+
</span>
|
| 408 |
+
)}
|
| 409 |
+
{saveSuccess && (
|
| 410 |
+
<span className='text-green-400 italic'>Saved!</span>
|
| 411 |
+
)}
|
| 412 |
+
</div>
|
| 413 |
+
|
| 414 |
+
{/* Row for buttons */}
|
| 415 |
+
<div className="flex items-center gap-3">
|
| 416 |
+
<EnhancedButton
|
| 417 |
+
onClick={handleRunClick}
|
| 418 |
+
disabled={calculatedDisabled}
|
| 419 |
+
className={`${buttonBase} ${
|
| 420 |
+
!isDirty && !hasPlaceholders // Attention markers do NOT affect pulse
|
| 421 |
+
? 'animate-pulse-green-download'
|
| 422 |
+
: 'bg-gray-600 hover:bg-gray-500 text-gray-200'
|
| 423 |
+
}`}
|
| 424 |
+
title={
|
| 425 |
+
!isDirty && !hasPlaceholders
|
| 426 |
+
? 'Run with current saved configuration'
|
| 427 |
+
: hasPlaceholders
|
| 428 |
+
? 'Run (placeholders present)'
|
| 429 |
+
: 'Run (unsaved changes present)'
|
| 430 |
+
}
|
| 431 |
+
particleColor="#34d399"
|
| 432 |
+
>
|
| 433 |
+
Run
|
| 434 |
+
</EnhancedButton>
|
| 435 |
+
<button
|
| 436 |
+
onClick={handleReset}
|
| 437 |
+
disabled={!isDirty || isSaving}
|
| 438 |
+
className={resetButtonClass}
|
| 439 |
+
>
|
| 440 |
+
Reset
|
| 441 |
+
</button>
|
| 442 |
+
<button
|
| 443 |
+
onClick={handleSave}
|
| 444 |
+
disabled={!isDirty || isSaving}
|
| 445 |
+
className={saveButtonClass}
|
| 446 |
+
>
|
| 447 |
+
{isSaving ? 'Saving…' : 'Save'}
|
| 448 |
+
</button>
|
| 449 |
+
</div>
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
{/* ---------------- Errors ---------------- */}
|
| 454 |
+
{(editorError || runError) && (
|
| 455 |
+
<div className='text-red-400 bg-red-900 bg-opacity-50 p-3 rounded border border-red-700 mb-4 text-sm'>
|
| 456 |
+
{editorError && <p>Editor/Save Error: {editorError}</p>}
|
| 457 |
+
{runError && !isRunModalOpen && <p>Run Error: {runError}</p>}
|
| 458 |
+
</div>
|
| 459 |
+
)}
|
| 460 |
+
|
| 461 |
+
{/* ---------------- Monaco Editor ---------------- */}
|
| 462 |
+
<div className='flex-grow relative border border-gray-700 rounded overflow-hidden min-h-[200px]'>
|
| 463 |
+
{isLoading ? (
|
| 464 |
+
<div className='absolute inset-0 flex items-center justify-center bg-gray-800'>
|
| 465 |
+
<FiLoader className='animate-spin text-gray-500 text-4xl' />
|
| 466 |
+
<p className='text-gray-400 ml-3'>Loading configuration…</p>
|
| 467 |
+
</div>
|
| 468 |
+
) : editorError && !originalContent ? (
|
| 469 |
+
<div className='absolute inset-0 flex items-center justify-center bg-gray-800 text-gray-500 px-4'>
|
| 470 |
+
Could not load content.
|
| 471 |
+
</div>
|
| 472 |
+
) : (
|
| 473 |
+
<Editor
|
| 474 |
+
height='100%'
|
| 475 |
+
language='yaml'
|
| 476 |
+
theme='vs-dark'
|
| 477 |
+
value={currentContent}
|
| 478 |
+
onChange={handleEditorChange}
|
| 479 |
+
onMount={handleEditorDidMount}
|
| 480 |
+
options={{
|
| 481 |
+
minimap: { enabled: true },
|
| 482 |
+
scrollBeyondLastLine: false,
|
| 483 |
+
fontSize: 14,
|
| 484 |
+
wordWrap: 'on',
|
| 485 |
+
automaticLayout: true,
|
| 486 |
+
}}
|
| 487 |
+
/>
|
| 488 |
+
)}
|
| 489 |
+
</div>
|
| 490 |
+
|
| 491 |
+
{/* ------------------- Modal stays unchanged ------------------- */}
|
| 492 |
+
<Modal isOpen={isRunModalOpen} onClose={() => { setIsRunModalOpen(false); setRunError(null); }} title="Specify Pipeline Node Path">
|
| 493 |
+
<p className="text-sm text-gray-400 mb-3">
|
| 494 |
+
The key <code className="bg-gray-700 px-1 rounded text-xs">pipeline:</code> was not found in your configuration.
|
| 495 |
+
Please provide the Python node path for the pipeline you want to run (e.g., <code className="bg-gray-700 px-1 rounded text-xs">pipelines.my_pipeline.run</code>).
|
| 496 |
+
</p>
|
| 497 |
+
<div className="relative">
|
| 498 |
+
<input
|
| 499 |
+
ref={nodePathInputRef}
|
| 500 |
+
type="text"
|
| 501 |
+
value={modalNodePath}
|
| 502 |
+
onChange={handleNodePathChange}
|
| 503 |
+
onKeyDown={handleModalKeyDown}
|
| 504 |
+
placeholder="Enter node path (e.g., pipelines.my_pipeline.run)"
|
| 505 |
+
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
| 506 |
+
disabled={isSubmittingRun || isFetchingPipelines}
|
| 507 |
+
/>
|
| 508 |
+
{isFetchingPipelines && <FiLoader className="animate-spin absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"/>}
|
| 509 |
+
{suggestions.length > 0 && (
|
| 510 |
+
<ul className="absolute z-10 w-full mt-1 bg-gray-700 border border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
|
| 511 |
+
{suggestions.map((s, index) => (
|
| 512 |
+
<li
|
| 513 |
+
key={s}
|
| 514 |
+
className={`px-3 py-2 cursor-pointer hover:bg-gray-600 ${index === selectedSuggestionIndex ? 'bg-blue-700' : ''}`}
|
| 515 |
+
onClick={() => handleSuggestionClick(s)}
|
| 516 |
+
>
|
| 517 |
+
{s}
|
| 518 |
+
</li>
|
| 519 |
+
))}
|
| 520 |
+
</ul>
|
| 521 |
+
)}
|
| 522 |
+
</div>
|
| 523 |
+
|
| 524 |
+
{/* Expandable List of All Pipelines */}
|
| 525 |
+
{!isFetchingPipelines && availablePipelines.length > 0 && (
|
| 526 |
+
<details className="mt-2 mb-3 rounded border border-gray-600 overflow-hidden">
|
| 527 |
+
<summary className="px-3 py-2 cursor-pointer bg-gray-700 hover:bg-gray-650 text-sm text-gray-300 flex justify-between items-center">
|
| 528 |
+
<span>Show All Available Pipelines ({availablePipelines.length})</span>
|
| 529 |
+
{/* Simple triangle indicator */}
|
| 530 |
+
<span className="details-marker">▼</span>
|
| 531 |
+
</summary>
|
| 532 |
+
<ul className="bg-gray-800 max-h-48 overflow-y-auto p-2">
|
| 533 |
+
{availablePipelines
|
| 534 |
+
.slice() // Create a copy
|
| 535 |
+
.sort((a, b) => a.localeCompare(b)) // Sort alphabetically
|
| 536 |
+
.map((pipeline) => (
|
| 537 |
+
<li
|
| 538 |
+
key={pipeline}
|
| 539 |
+
className="px-3 py-1.5 cursor-pointer hover:bg-gray-700 text-gray-200 text-sm rounded"
|
| 540 |
+
onClick={(e) => {
|
| 541 |
+
handleSuggestionClick(pipeline);
|
| 542 |
+
// Optionally close the details element
|
| 543 |
+
e.target.closest('details')?.removeAttribute('open');
|
| 544 |
+
}}
|
| 545 |
+
>
|
| 546 |
+
{pipeline}
|
| 547 |
+
</li>
|
| 548 |
+
))}
|
| 549 |
+
</ul>
|
| 550 |
+
</details>
|
| 551 |
+
)}
|
| 552 |
+
{/* Add CSS for details marker if not already global */}
|
| 553 |
+
<style>{`
|
| 554 |
+
details > summary { list-style: none; }
|
| 555 |
+
details > summary::-webkit-details-marker { display: none; }
|
| 556 |
+
details > summary .details-marker { transition: transform 0.2s; }
|
| 557 |
+
details[open] > summary .details-marker { transform: rotate(180deg); }
|
| 558 |
+
`}</style>
|
| 559 |
+
|
| 560 |
+
{runError && <p className="text-red-400 text-xs mt-2">{runError}</p>}
|
| 561 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 562 |
+
<button
|
| 563 |
+
type="button"
|
| 564 |
+
onClick={() => { setIsRunModalOpen(false); setRunError(null); }}
|
| 565 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 transition-colors disabled:opacity-50"
|
| 566 |
+
disabled={isSubmittingRun}
|
| 567 |
+
>
|
| 568 |
+
Cancel
|
| 569 |
+
</button>
|
| 570 |
+
<button
|
| 571 |
+
type="button"
|
| 572 |
+
onClick={handleModalSubmit}
|
| 573 |
+
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 flex items-center"
|
| 574 |
+
disabled={isSubmittingRun || !modalNodePath.trim()}
|
| 575 |
+
>
|
| 576 |
+
{isSubmittingRun && <FiLoader className="animate-spin mr-2"/>} Run Pipeline
|
| 577 |
+
</button>
|
| 578 |
+
</div>
|
| 579 |
+
</Modal>
|
| 580 |
+
|
| 581 |
+
{/* ---------- Monaco decoration CSS ---------- */}
|
| 582 |
+
<style>{`
|
| 583 |
+
.placeholder-line-highlight {
|
| 584 |
+
background-color: rgba(252, 211, 77, 0.25) !important; /* amber-200 */
|
| 585 |
+
}
|
| 586 |
+
.attention-line-highlight {
|
| 587 |
+
background-color: rgba(103, 232, 249, 0.2) !important; /* cyan-300 */
|
| 588 |
+
border-left: 3px solid #06b6d4; /* cyan-600 */
|
| 589 |
+
}
|
| 590 |
+
`}</style>
|
| 591 |
+
</div>
|
| 592 |
+
);
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
export default ConfigEditor;
|
atk-interface/src/pages/ConfigSelector.jsx
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import { Link, useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Tree } from 'react-arborist';
|
| 4 |
+
import { FiFile, FiFolder, FiFolderPlus, FiCopy, FiTrash2, FiLoader, FiAlertTriangle, FiCheckCircle, FiChevronDown, FiChevronRight } from 'react-icons/fi';
|
| 5 |
+
import { fetchConfigStructure, deleteConfigItem, moveConfigItem, createConfigDirectory, duplicateConfig, fetchAvailableConfigAliases } from '../api'; // Added new API functions
|
| 6 |
+
import Modal from '../components/Modal';
|
| 7 |
+
import ScrollingTextBackground from '../components/ScrollingTextBackground'; // Import the background component
|
| 8 |
+
import EnhancedButton from '../components/EnhancedButton'; // Import the new button component
|
| 9 |
+
|
| 10 |
+
// --- CSS Keyframes for Animations ---
|
| 11 |
+
// NOTE: Ideally, move these to a global CSS file or tailwind.config.js
|
| 12 |
+
// const animationStyles = `
|
| 13 |
+
// @keyframes shake {
|
| 14 |
+
// 0%, 100% { transform: translateX(0); }
|
| 15 |
+
// 10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
|
| 16 |
+
// 20%, 40%, 60%, 80% { transform: translateX(2px); }
|
| 17 |
+
// }
|
| 18 |
+
// .animate-shake {
|
| 19 |
+
// animation: shake 0.4s ease-in-out; /* Shorter duration, run once per selection change */
|
| 20 |
+
// }
|
| 21 |
+
//
|
| 22 |
+
// @keyframes pulse-red {
|
| 23 |
+
// 0%, 100% { background-color: #dc2626; color: white; } /* bg-red-600 text-white */
|
| 24 |
+
// 50% { background-color: #374151; color: #f87171; } /* bg-gray-700 text-red-400 */
|
| 25 |
+
// }
|
| 26 |
+
// .animate-pulse-red {
|
| 27 |
+
// animation: pulse-red 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 28 |
+
// }
|
| 29 |
+
//
|
| 30 |
+
// @keyframes pulse-white {
|
| 31 |
+
// 0%, 100% { background-color: rgba(255, 255, 255, 0.1); } /* Slightly white background */
|
| 32 |
+
// 50% { background-color: rgba(255, 255, 255, 0.3); } /* More prominent white */
|
| 33 |
+
// }
|
| 34 |
+
// .animate-pulse-white {
|
| 35 |
+
// /* Apply this animation on top of other background styles */
|
| 36 |
+
// animation: pulse-white 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 37 |
+
// }
|
| 38 |
+
//
|
| 39 |
+
// /* Additional subtle pulse animations for guidance */
|
| 40 |
+
// @keyframes pulse-bg-subtle {
|
| 41 |
+
// 50% { background-color: rgba(75, 85, 99, 0.8); } /* bg-gray-600 slightly lighter */
|
| 42 |
+
// }
|
| 43 |
+
// .animate-pulse-bg-subtle {
|
| 44 |
+
// animation: pulse-bg-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 45 |
+
// background-color: rgba(255, 255, 255, 0.5); /* More intense white color */
|
| 46 |
+
// }
|
| 47 |
+
//
|
| 48 |
+
// @keyframes pulse-border-subtle {
|
| 49 |
+
// 50% { border-color: rgba(107, 114, 128, 0.8); } /* border-gray-500 slightly lighter */
|
| 50 |
+
// }
|
| 51 |
+
// .animate-pulse-border-subtle {
|
| 52 |
+
// animation: pulse-border-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 53 |
+
// }
|
| 54 |
+
//
|
| 55 |
+
// @keyframes pulse-blue-gold {
|
| 56 |
+
// 0% { background-color: #2563eb; box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7); } /* bg-blue-600, shadow-amber-400 */
|
| 57 |
+
// 70% { background-color: #3b82f6; box-shadow: 0 0 10px 10px rgba(251, 191, 36, 0); } /* bg-blue-500 */
|
| 58 |
+
// 100% { background-color: #2563eb; box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
| 59 |
+
// }
|
| 60 |
+
// .animate-pulse-blue-gold {
|
| 61 |
+
// animation: pulse-blue-gold 2s infinite;
|
| 62 |
+
// }
|
| 63 |
+
// `;
|
| 64 |
+
|
| 65 |
+
// --- Type Definitions ---
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* @typedef {object} ConfigNodeData
|
| 69 |
+
* @property {string} id - Unique ID (relative path)
|
| 70 |
+
* @property {string} name - Display name (basename)
|
| 71 |
+
* @property {boolean} [isInternal] - react-arborist internal node flag
|
| 72 |
+
* @property {string} relativePath - Full relative path from ./external_configs/
|
| 73 |
+
* @property {boolean} is_dir - True if it's a directory
|
| 74 |
+
* @property {ConfigNodeData[] | null} [children] - Nested children
|
| 75 |
+
*/
|
| 76 |
+
|
| 77 |
+
// --- Helper Functions ---
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Adapts API data structure to the structure expected by react-arborist.
|
| 81 |
+
* @param {Array<object>} apiData - Data from the API (/configs/structure)
|
| 82 |
+
* @returns {ConfigNodeData[]}
|
| 83 |
+
*/
|
| 84 |
+
const adaptApiDataToNodeData = (apiData) => {
|
| 85 |
+
if (!Array.isArray(apiData)) return [];
|
| 86 |
+
return apiData.map(item => ({
|
| 87 |
+
id: item.path,
|
| 88 |
+
name: item.path.split('/').pop() || item.path,
|
| 89 |
+
relativePath: item.path,
|
| 90 |
+
is_dir: item.is_dir,
|
| 91 |
+
// Important: Only add children array if it's a directory. Leaf nodes should not have 'children'.
|
| 92 |
+
children: item.is_dir ? (item.children ? adaptApiDataToNodeData(item.children) : null) : undefined,
|
| 93 |
+
}));
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// --- Node Renderer Component ---
|
| 97 |
+
|
| 98 |
+
/** @param {import("react-arborist").NodeRendererProps<ConfigNodeData> & { navigate: Function, onNodeClick: Function, lastClick: { id: string | null, time: number }, newlyCreatedItemId: string | null } } props */
|
| 99 |
+
function Node({ node, style, dragHandle, navigate, onNodeClick, lastClick, newlyCreatedItemId }) {
|
| 100 |
+
const Icon = node.isLeaf ? FiFile : FiFolder;
|
| 101 |
+
const iconColor = node.isLeaf ? 'text-blue-400' : 'text-yellow-500';
|
| 102 |
+
const isNewlyCreated = node.id === newlyCreatedItemId;
|
| 103 |
+
|
| 104 |
+
const handleRowClick = (e) => {
|
| 105 |
+
const now = Date.now();
|
| 106 |
+
const DOUBLE_CLICK_THRESHOLD = 300; // ms
|
| 107 |
+
|
| 108 |
+
if (lastClick.id === node.id && (now - lastClick.time) < DOUBLE_CLICK_THRESHOLD) {
|
| 109 |
+
// Double click detected
|
| 110 |
+
if (node.isLeaf) {
|
| 111 |
+
navigate(`/configs/${encodeURIComponent(node.data.relativePath)}`);
|
| 112 |
+
} else if (node.isInternal) { // Handle folder double-click
|
| 113 |
+
node.toggle(); // Toggle folder open/closed state
|
| 114 |
+
}
|
| 115 |
+
// Reset last click to prevent triple click issues
|
| 116 |
+
onNodeClick(null, 0);
|
| 117 |
+
} else {
|
| 118 |
+
// Single click
|
| 119 |
+
node.handleClick(e); // Let react-arborist handle selection
|
| 120 |
+
onNodeClick(node.id, now); // Update last click state
|
| 121 |
+
}
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const handleCaretClick = (e) => {
|
| 125 |
+
e.stopPropagation(); // Prevent row click logic if caret is clicked
|
| 126 |
+
if (node.isInternal) {
|
| 127 |
+
node.toggle(); // Toggle folder open/closed
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
// Content structure: Caret (for folders), Icon, Name
|
| 132 |
+
const nodeContent = (
|
| 133 |
+
<div
|
| 134 |
+
ref={dragHandle} // Files and folders can be dragged
|
| 135 |
+
style={style}
|
| 136 |
+
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer
|
| 137 |
+
${node.isSelected ? 'bg-blue-700' : 'hover:bg-gray-700'}
|
| 138 |
+
${node.isEditing ? 'bg-gray-600' : ''}
|
| 139 |
+
${isNewlyCreated ? 'animate-pulse-white' : ''} /* Apply pulse animation */
|
| 140 |
+
`}
|
| 141 |
+
onClick={handleRowClick}
|
| 142 |
+
>
|
| 143 |
+
{/* Caret */}
|
| 144 |
+
<span
|
| 145 |
+
className="w-6 text-center flex items-center justify-center"
|
| 146 |
+
onClick={handleCaretClick}
|
| 147 |
+
>
|
| 148 |
+
{/* Show caret only for folders (internal nodes) */}
|
| 149 |
+
{node.isInternal && (node.isOpen ? <FiChevronDown /> : <FiChevronRight />)}
|
| 150 |
+
</span>
|
| 151 |
+
|
| 152 |
+
{/* Icon + Name */}
|
| 153 |
+
<Icon className={`${iconColor} flex-shrink-0 mr-1`} />
|
| 154 |
+
<span className="truncate flex-grow text-gray-200">
|
| 155 |
+
{node.data.name}
|
| 156 |
+
</span>
|
| 157 |
+
</div>
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
+
// Return the div directly, no Link wrapper needed anymore
|
| 161 |
+
return nodeContent;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// --- Main ConfigSelector Component ---
|
| 165 |
+
|
| 166 |
+
function ConfigSelector() {
|
| 167 |
+
/** @type {[ConfigNodeData[] | null, Function]} */
|
| 168 |
+
const [treeData, setTreeData] = useState(null); // Start as null to indicate initial loading
|
| 169 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 170 |
+
const [error, setError] = useState(null);
|
| 171 |
+
/** @type {[ConfigNodeData[], Function]} */
|
| 172 |
+
const [selectedNodes, setSelectedNodes] = useState([]);
|
| 173 |
+
const treeRef = useRef(null);
|
| 174 |
+
const navigate = useNavigate(); // Get navigate function
|
| 175 |
+
|
| 176 |
+
// State for manual double-click tracking
|
| 177 |
+
const [lastClick, setLastClick] = useState({ id: null, time: 0 });
|
| 178 |
+
// State for newly created item highlight
|
| 179 |
+
const [newlyCreatedItemId, setNewlyCreatedItemId] = useState(null);
|
| 180 |
+
|
| 181 |
+
const handleNodeClick = (id, time) => {
|
| 182 |
+
setLastClick({ id, time });
|
| 183 |
+
setNewlyCreatedItemId(null); // Clear highlight on any node click
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
// Modals State
|
| 187 |
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
| 188 |
+
const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false);
|
| 189 |
+
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
|
| 190 |
+
const [newFolderName, setNewFolderName] = useState('');
|
| 191 |
+
const [duplicateSourceAlias, setDuplicateSourceAlias] = useState('');
|
| 192 |
+
const [duplicateDestPath, setDuplicateDestPath] = useState('');
|
| 193 |
+
const [availableAliases, setAvailableAliases] = useState([]);
|
| 194 |
+
const [aliasSuggestions, setAliasSuggestions] = useState([]);
|
| 195 |
+
const [isFetchingAliases, setIsFetchingAliases] = useState(false);
|
| 196 |
+
const [modalError, setModalError] = useState(null);
|
| 197 |
+
const [isSubmittingModal, setIsSubmittingModal] = useState(false);
|
| 198 |
+
const aliasInputRef = useRef(null);
|
| 199 |
+
|
| 200 |
+
// Action Status State
|
| 201 |
+
const [isMoving, setIsMoving] = useState(false);
|
| 202 |
+
const [moveError, setMoveError] = useState(null);
|
| 203 |
+
const [actionStatus, setActionStatus] = useState({ message: '', error: false, success: false }); // For delete/create/duplicate feedback
|
| 204 |
+
|
| 205 |
+
// State for shake animation trigger
|
| 206 |
+
const [isShaking, setIsShaking] = useState(false); // RE-ADD state for shaking
|
| 207 |
+
|
| 208 |
+
// State to track if alias details section is open in the duplicate modal
|
| 209 |
+
const [isAliasDetailsOpen, setIsAliasDetailsOpen] = useState(false);
|
| 210 |
+
|
| 211 |
+
// Refs for modal inputs
|
| 212 |
+
const createFolderInputRef = useRef(null);
|
| 213 |
+
|
| 214 |
+
// --- Data Fetching ---
|
| 215 |
+
const fetchInitialData = useCallback(async () => {
|
| 216 |
+
// Only set loading true on initial load (treeData is null)
|
| 217 |
+
if (treeData === null) setIsLoading(true);
|
| 218 |
+
setError(null);
|
| 219 |
+
setMoveError(null);
|
| 220 |
+
setActionStatus({ message: '', error: false, success: false }); // Clear status on refresh
|
| 221 |
+
|
| 222 |
+
try {
|
| 223 |
+
const structure = await fetchConfigStructure();
|
| 224 |
+
let adaptedData = adaptApiDataToNodeData(structure);
|
| 225 |
+
|
| 226 |
+
// Sort top-level items (folders first, then alphabetically)
|
| 227 |
+
adaptedData.sort((a, b) => {
|
| 228 |
+
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
| 229 |
+
return a.name.localeCompare(b.name);
|
| 230 |
+
});
|
| 231 |
+
setTreeData(adaptedData);
|
| 232 |
+
} catch (e) {
|
| 233 |
+
console.error("Failed to fetch config structure:", e);
|
| 234 |
+
setError(e.message || 'Failed to load configurations. Is the API server running?');
|
| 235 |
+
setTreeData([]); // Set to empty array on error to stop loading spinner
|
| 236 |
+
} finally {
|
| 237 |
+
setIsLoading(false);
|
| 238 |
+
}
|
| 239 |
+
}, [treeData]); // Depend on treeData to re-run only if it's null
|
| 240 |
+
|
| 241 |
+
useEffect(() => {
|
| 242 |
+
// Fetch data only on initial mount when treeData is null
|
| 243 |
+
if (treeData === null) {
|
| 244 |
+
fetchInitialData();
|
| 245 |
+
}
|
| 246 |
+
}, [fetchInitialData, treeData]);
|
| 247 |
+
|
| 248 |
+
// Effect to handle newly created items: expand parents and scroll into view
|
| 249 |
+
useEffect(() => {
|
| 250 |
+
if (newlyCreatedItemId && treeRef.current && treeData) {
|
| 251 |
+
const node = treeRef.current.get(newlyCreatedItemId);
|
| 252 |
+
if (node) {
|
| 253 |
+
// Expand all parents
|
| 254 |
+
let parent = node.parent;
|
| 255 |
+
while (parent && !parent.isOpen) {
|
| 256 |
+
// Check if parent.id exists before trying to open
|
| 257 |
+
if (parent.id) {
|
| 258 |
+
treeRef.current.open(parent.id, { autoSelect: false });
|
| 259 |
+
} else {
|
| 260 |
+
// If the parent is the root (which might not have an id in some setups)
|
| 261 |
+
// or if there's an issue, break the loop.
|
| 262 |
+
console.warn("Could not open parent, ID missing:", parent);
|
| 263 |
+
break;
|
| 264 |
+
}
|
| 265 |
+
parent = parent.parent; // Move up the tree
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Scroll the new node into view after a short delay to allow rendering
|
| 269 |
+
// It seems react-arborist might handle scrolling automatically on open,
|
| 270 |
+
// but this ensures it if needed, especially after multiple opens.
|
| 271 |
+
const timerId = setTimeout(() => {
|
| 272 |
+
treeRef.current?.scrollTo(newlyCreatedItemId, "center"); // or "nearest", "start", "end"
|
| 273 |
+
}, 100); // Small delay
|
| 274 |
+
|
| 275 |
+
return () => clearTimeout(timerId); // Cleanup timeout on unmount or change
|
| 276 |
+
} else {
|
| 277 |
+
console.warn(`Node with ID ${newlyCreatedItemId} not found in tree after creation.`);
|
| 278 |
+
// Optionally reset the ID if the node isn't found after a refresh
|
| 279 |
+
// setNewlyCreatedItemId(null);
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}, [newlyCreatedItemId, treeData]); // Rerun when ID changes or tree data updates
|
| 283 |
+
|
| 284 |
+
// Effect to trigger shake animation on selection change - RE-ADD
|
| 285 |
+
useEffect(() => {
|
| 286 |
+
if (selectedNodes.length > 0) {
|
| 287 |
+
setIsShaking(true);
|
| 288 |
+
const timer = setTimeout(() => {
|
| 289 |
+
setIsShaking(false);
|
| 290 |
+
}, 400); // Duration of the shake animation
|
| 291 |
+
return () => clearTimeout(timer); // Cleanup timer on unmount or if selection changes again quickly
|
| 292 |
+
} else {
|
| 293 |
+
setIsShaking(false); // Ensure shaking stops if selection is cleared
|
| 294 |
+
}
|
| 295 |
+
}, [selectedNodes.length]); // Depend only on the length changing
|
| 296 |
+
|
| 297 |
+
// --- Alias Fetching for Duplicate Modal ---
|
| 298 |
+
const fetchAliases = useCallback(async () => {
|
| 299 |
+
if (isFetchingAliases) return;
|
| 300 |
+
setIsFetchingAliases(true);
|
| 301 |
+
setModalError(null); // Clear previous errors specific to alias fetching
|
| 302 |
+
try {
|
| 303 |
+
const aliases = await fetchAvailableConfigAliases(); // Assumes this API function exists
|
| 304 |
+
setAvailableAliases(Array.isArray(aliases) ? aliases : []);
|
| 305 |
+
} catch (e) {
|
| 306 |
+
console.error("Failed to fetch config aliases:", e);
|
| 307 |
+
setModalError("Could not load config aliases for duplication.");
|
| 308 |
+
setAvailableAliases([]);
|
| 309 |
+
} finally {
|
| 310 |
+
setIsFetchingAliases(false);
|
| 311 |
+
}
|
| 312 |
+
}, [isFetchingAliases]);
|
| 313 |
+
|
| 314 |
+
// --- Event Handlers ---
|
| 315 |
+
|
| 316 |
+
const handleSelect = (nodes) => {
|
| 317 |
+
setSelectedNodes(nodes.map(n => n.data));
|
| 318 |
+
if (nodes.length > 0) {
|
| 319 |
+
setNewlyCreatedItemId(null); // Clear highlight on selection change
|
| 320 |
+
}
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
const handleToggle = (id) => {
|
| 324 |
+
// Optional: Add logging or specific actions on toggle if needed
|
| 325 |
+
// console.log('Toggled:', id);
|
| 326 |
+
setNewlyCreatedItemId(null); // Clear highlight on toggle
|
| 327 |
+
};
|
| 328 |
+
|
| 329 |
+
/** @param {import("react-arborist").MoveHandler<ConfigNodeData>} args */
|
| 330 |
+
const handleMove = async ({ dragIds, parentId, index }) => {
|
| 331 |
+
setIsMoving(true);
|
| 332 |
+
setMoveError(null);
|
| 333 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 334 |
+
|
| 335 |
+
const parentNode = parentId ? treeRef.current?.get(parentId) : null;
|
| 336 |
+
const parentPath = parentNode ? parentNode.data.relativePath : '.'; // Root is '.'
|
| 337 |
+
|
| 338 |
+
// Prevent moving items into a file
|
| 339 |
+
if (parentNode && !parentNode.data.is_dir) {
|
| 340 |
+
setMoveError("Cannot move items into a file.");
|
| 341 |
+
setIsMoving(false);
|
| 342 |
+
return;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
let successCount = 0;
|
| 346 |
+
let firstError = null;
|
| 347 |
+
|
| 348 |
+
const results = await Promise.allSettled(
|
| 349 |
+
dragIds.map(id => {
|
| 350 |
+
const node = treeRef.current?.get(id);
|
| 351 |
+
if (!node) return Promise.reject(new Error(`Node with ID ${id} not found.`));
|
| 352 |
+
|
| 353 |
+
const sourcePath = node.data.relativePath;
|
| 354 |
+
const itemName = node.data.name;
|
| 355 |
+
// Construct destination path: parentPath/itemName
|
| 356 |
+
const cleanParentPath = parentPath === '.' ? '' : parentPath.replace(/^\/+|\/+$/g, '');
|
| 357 |
+
const cleanItemName = itemName.replace(/^\/+/,'');
|
| 358 |
+
const destinationPath = cleanParentPath ? `${cleanParentPath}/${cleanItemName}` : cleanItemName;
|
| 359 |
+
|
| 360 |
+
console.log(`Attempting move: ${sourcePath} -> ${destinationPath}`);
|
| 361 |
+
return moveConfigItem(sourcePath, destinationPath); // Use moveConfigItem
|
| 362 |
+
})
|
| 363 |
+
);
|
| 364 |
+
|
| 365 |
+
results.forEach((result, i) => {
|
| 366 |
+
const nodeId = dragIds[i];
|
| 367 |
+
const node = treeRef.current?.get(nodeId);
|
| 368 |
+
const itemName = node?.data?.name || `Item ${i+1}`;
|
| 369 |
+
if (result.status === 'fulfilled') {
|
| 370 |
+
console.log(`Successfully moved ${itemName}:`, result.value);
|
| 371 |
+
successCount++;
|
| 372 |
+
} else {
|
| 373 |
+
console.error(`Failed to move ${itemName}:`, result.reason);
|
| 374 |
+
if (!firstError) {
|
| 375 |
+
firstError = result.reason?.message || `Failed to move ${itemName}.`;
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
setIsMoving(false);
|
| 381 |
+
|
| 382 |
+
if (firstError) {
|
| 383 |
+
setMoveError(`Move Error: ${firstError}${successCount > 0 ? ` (${successCount} items moved successfully)` : ''}`);
|
| 384 |
+
// Clear error after delay
|
| 385 |
+
setTimeout(() => setMoveError(null), 8000);
|
| 386 |
+
} else {
|
| 387 |
+
console.log(`Successfully moved ${successCount} item(s).`);
|
| 388 |
+
// Optionally show success message in status bar
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
// Refresh tree if any move succeeded or failed to reflect server state
|
| 392 |
+
if (successCount > 0 || firstError) {
|
| 393 |
+
await fetchInitialData();
|
| 394 |
+
setSelectedNodes([]); // Clear selection after move
|
| 395 |
+
treeRef.current?.deselectAll();
|
| 396 |
+
}
|
| 397 |
+
setNewlyCreatedItemId(null); // Clear highlight on move
|
| 398 |
+
};
|
| 399 |
+
|
| 400 |
+
const handleDelete = async () => {
|
| 401 |
+
if (selectedNodes.length === 0 || isSubmittingModal) return;
|
| 402 |
+
|
| 403 |
+
const itemsToDelete = [...selectedNodes];
|
| 404 |
+
setIsSubmittingModal(true);
|
| 405 |
+
setModalError(null);
|
| 406 |
+
setActionStatus({ message: '', error: false, success: false }); // Clear general status
|
| 407 |
+
let successCount = 0;
|
| 408 |
+
let firstError = null;
|
| 409 |
+
|
| 410 |
+
const results = await Promise.allSettled(
|
| 411 |
+
itemsToDelete.map(node => {
|
| 412 |
+
// Call deleteConfigItem for both files and folders
|
| 413 |
+
return deleteConfigItem(node.relativePath);
|
| 414 |
+
})
|
| 415 |
+
);
|
| 416 |
+
|
| 417 |
+
results.forEach((result, index) => {
|
| 418 |
+
if (result.status === 'fulfilled') {
|
| 419 |
+
successCount++;
|
| 420 |
+
} else {
|
| 421 |
+
console.error(`Failed to delete ${itemsToDelete[index].relativePath}:`, result.reason);
|
| 422 |
+
if (!firstError) {
|
| 423 |
+
firstError = result.reason?.message || `Failed to delete ${itemsToDelete[index].name}.`;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
setIsSubmittingModal(false);
|
| 429 |
+
|
| 430 |
+
if (firstError) {
|
| 431 |
+
// Display error within the modal
|
| 432 |
+
setModalError(`Error: ${firstError}${successCount > 0 ? ` (${successCount} file(s) deleted successfully)` : ''}`);
|
| 433 |
+
} else {
|
| 434 |
+
setActionStatus({ message: `Successfully deleted ${successCount} file(s).`, error: false, success: true });
|
| 435 |
+
setIsDeleteModalOpen(false); // Close modal on full success
|
| 436 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 5000); // Clear status message
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// Refresh tree if any deletions succeeded
|
| 440 |
+
if (successCount > 0) {
|
| 441 |
+
await fetchInitialData();
|
| 442 |
+
setSelectedNodes([]); // Clear selection
|
| 443 |
+
treeRef.current?.deselectAll(); // Also deselect in the tree instance
|
| 444 |
+
}
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
const handleCreateFolder = async () => {
|
| 448 |
+
if (!newFolderName.trim() || isSubmittingModal) return;
|
| 449 |
+
|
| 450 |
+
// Determine parent path based on selection (similar to InputManager)
|
| 451 |
+
let parentPath = '.';
|
| 452 |
+
let parentNode = null; // Keep track of the parent node
|
| 453 |
+
const selectedDir = selectedNodes.find(n => n.is_dir);
|
| 454 |
+
if (selectedNodes.length === 1 && selectedDir) {
|
| 455 |
+
parentPath = selectedDir.relativePath;
|
| 456 |
+
parentNode = treeRef.current?.get(selectedDir.id); // Get the node itself
|
| 457 |
+
} else if (selectedNodes.length > 0) {
|
| 458 |
+
const firstNodeId = selectedNodes[0].id;
|
| 459 |
+
const firstNode = treeRef.current?.get(firstNodeId);
|
| 460 |
+
const parent = firstNode?.parent;
|
| 461 |
+
if (parent) {
|
| 462 |
+
parentPath = parent.data.relativePath;
|
| 463 |
+
parentNode = parent; // Get the parent node
|
| 464 |
+
} else {
|
| 465 |
+
parentPath = '.'; // Default to root if no parent found
|
| 466 |
+
parentNode = null;
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
// --- START EDIT: Attempt to open the parent directory ---
|
| 471 |
+
if (parentNode && parentNode.id && !parentNode.isOpen && treeRef.current) {
|
| 472 |
+
try {
|
| 473 |
+
console.log(`LOG: Config.handleCreateFolder attempting to open parent node: ${parentNode.id}`);
|
| 474 |
+
await treeRef.current.open(parentNode.id, { autoSelect: false });
|
| 475 |
+
console.log(`LOG: Config.handleCreateFolder successfully opened parent node: ${parentNode.id}`);
|
| 476 |
+
} catch (openError) {
|
| 477 |
+
console.warn(`LOG: Config.handleCreateFolder failed to open parent node ${parentNode.id}:`, openError);
|
| 478 |
+
// Continue anyway
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
// --- END EDIT ---
|
| 482 |
+
|
| 483 |
+
const cleanParentPath = parentPath === '.' ? '' : parentPath.replace(/^\/+|\/+$/g, '');
|
| 484 |
+
const cleanNewFolderName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
|
| 485 |
+
const fullPath = cleanParentPath ? `${cleanParentPath}/${cleanNewFolderName}` : cleanNewFolderName;
|
| 486 |
+
|
| 487 |
+
setIsSubmittingModal(true);
|
| 488 |
+
setModalError(null);
|
| 489 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 490 |
+
|
| 491 |
+
try {
|
| 492 |
+
await createConfigDirectory(fullPath); // Use createConfigDirectory
|
| 493 |
+
setNewFolderName('');
|
| 494 |
+
setIsCreateFolderModalOpen(false);
|
| 495 |
+
setActionStatus({ message: `Successfully created folder '${cleanNewFolderName}'.`, error: false, success: true });
|
| 496 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 5000);
|
| 497 |
+
// Set the newly created ID *before* fetching data
|
| 498 |
+
setNewlyCreatedItemId(fullPath);
|
| 499 |
+
await fetchInitialData(); // Refresh tree
|
| 500 |
+
} catch (e) {
|
| 501 |
+
console.error("Failed to create directory:", e);
|
| 502 |
+
setModalError(e.message || 'Could not create folder.'); // Show error in modal
|
| 503 |
+
} finally {
|
| 504 |
+
setIsSubmittingModal(false);
|
| 505 |
+
}
|
| 506 |
+
};
|
| 507 |
+
|
| 508 |
+
const handleDuplicate = async () => {
|
| 509 |
+
if (!duplicateSourceAlias.trim() || !duplicateDestPath.trim() || isSubmittingModal) return;
|
| 510 |
+
|
| 511 |
+
const destPathClean = duplicateDestPath.trim().replace(/^\/+|\/+$/g, '');
|
| 512 |
+
const destPathParts = destPathClean.split('/');
|
| 513 |
+
let destParentPath = '.';
|
| 514 |
+
let destParentNode = null;
|
| 515 |
+
|
| 516 |
+
// Determine the parent path and node for the destination
|
| 517 |
+
if (destPathParts.length > 1) {
|
| 518 |
+
destParentPath = destPathParts.slice(0, -1).join('/');
|
| 519 |
+
destParentNode = treeRef.current?.get(destParentPath);
|
| 520 |
+
} // else it's in the root, parentNode remains null
|
| 521 |
+
|
| 522 |
+
// --- START EDIT: Attempt to open the destination parent directory ---
|
| 523 |
+
if (destParentNode && destParentNode.id && !destParentNode.isOpen && treeRef.current) {
|
| 524 |
+
try {
|
| 525 |
+
console.log(`LOG: Config.handleDuplicate attempting to open dest parent node: ${destParentNode.id}`);
|
| 526 |
+
await treeRef.current.open(destParentNode.id, { autoSelect: false });
|
| 527 |
+
console.log(`LOG: Config.handleDuplicate successfully opened dest parent node: ${destParentNode.id}`);
|
| 528 |
+
} catch (openError) {
|
| 529 |
+
console.warn(`LOG: Config.handleDuplicate failed to open dest parent node ${destParentNode.id}:`, openError);
|
| 530 |
+
// Continue duplication anyway
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
// --- END EDIT ---
|
| 534 |
+
|
| 535 |
+
setIsSubmittingModal(true);
|
| 536 |
+
setModalError(null);
|
| 537 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 538 |
+
|
| 539 |
+
try {
|
| 540 |
+
await duplicateConfig(duplicateSourceAlias, destPathClean);
|
| 541 |
+
setDuplicateSourceAlias('');
|
| 542 |
+
setDuplicateDestPath('');
|
| 543 |
+
setAliasSuggestions([]);
|
| 544 |
+
setIsDuplicateModalOpen(false);
|
| 545 |
+
setActionStatus({ message: `Successfully duplicated '${duplicateSourceAlias}' to '${destPathClean}'.`, error: false, success: true });
|
| 546 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 5000);
|
| 547 |
+
// Set the newly created ID *before* fetching data
|
| 548 |
+
setNewlyCreatedItemId(destPathClean);
|
| 549 |
+
await fetchInitialData(); // Refresh tree
|
| 550 |
+
} catch (e) {
|
| 551 |
+
console.error("Failed to duplicate config:", e);
|
| 552 |
+
setModalError(e.message || 'Could not duplicate config.'); // Show error in modal
|
| 553 |
+
} finally {
|
| 554 |
+
setIsSubmittingModal(false);
|
| 555 |
+
}
|
| 556 |
+
};
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
// --- Modal Opening/Closing ---
|
| 560 |
+
|
| 561 |
+
const openCreateFolderModal = () => {
|
| 562 |
+
setNewFolderName('');
|
| 563 |
+
setModalError(null);
|
| 564 |
+
setIsCreateFolderModalOpen(true);
|
| 565 |
+
setNewlyCreatedItemId(null); // Clear highlight when opening modal
|
| 566 |
+
// Autofocus the input field
|
| 567 |
+
setTimeout(() => createFolderInputRef.current?.focus(), 100);
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
const openDeleteModal = () => {
|
| 571 |
+
setModalError(null);
|
| 572 |
+
setIsDeleteModalOpen(true);
|
| 573 |
+
setNewlyCreatedItemId(null); // Clear highlight when opening modal
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
const openDuplicateModal = () => {
|
| 577 |
+
setDuplicateSourceAlias('');
|
| 578 |
+
setDuplicateDestPath('');
|
| 579 |
+
setModalError(null);
|
| 580 |
+
setAliasSuggestions([]);
|
| 581 |
+
setIsAliasDetailsOpen(false); // Reset details open state when modal opens
|
| 582 |
+
setIsDuplicateModalOpen(true);
|
| 583 |
+
setNewlyCreatedItemId(null); // Clear highlight when opening modal
|
| 584 |
+
// Fetch aliases when the modal opens, if not already fetched
|
| 585 |
+
if (availableAliases.length === 0) {
|
| 586 |
+
fetchAliases();
|
| 587 |
+
}
|
| 588 |
+
// Focus input after a short delay
|
| 589 |
+
setTimeout(() => aliasInputRef.current?.focus(), 100);
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
// --- Alias Suggestion Logic ---
|
| 593 |
+
const handleAliasInputChange = (e) => {
|
| 594 |
+
const value = e.target.value;
|
| 595 |
+
setDuplicateSourceAlias(value);
|
| 596 |
+
if (value && availableAliases.length > 0) {
|
| 597 |
+
const lowerValue = value.toLowerCase();
|
| 598 |
+
setAliasSuggestions(
|
| 599 |
+
availableAliases.filter(a => a.toLowerCase().includes(lowerValue)).slice(0, 5) // Limit suggestions
|
| 600 |
+
);
|
| 601 |
+
} else {
|
| 602 |
+
setAliasSuggestions([]);
|
| 603 |
+
}
|
| 604 |
+
};
|
| 605 |
+
|
| 606 |
+
const handleAliasSuggestionClick = (suggestion) => {
|
| 607 |
+
setDuplicateSourceAlias(suggestion);
|
| 608 |
+
setAliasSuggestions([]);
|
| 609 |
+
aliasInputRef.current?.focus();
|
| 610 |
+
};
|
| 611 |
+
|
| 612 |
+
// --- Utilities ---
|
| 613 |
+
|
| 614 |
+
// Function to clear selection when clicking outside the tree or action buttons
|
| 615 |
+
const handleBackgroundClick = useCallback((e) => {
|
| 616 |
+
// Don't clear if a modal is open
|
| 617 |
+
if (isDeleteModalOpen || isCreateFolderModalOpen || isDuplicateModalOpen) return;
|
| 618 |
+
|
| 619 |
+
const clickedOnRow = e.target.closest('[role="treeitem"]');
|
| 620 |
+
const clickedOnScrollbar = e.target.closest('.simplebar-scrollbar'); // Adjust if using a specific scrollbar library
|
| 621 |
+
const clickedOnActionButton = e.target.closest('[data-action-button="true"]');
|
| 622 |
+
const clickedOnModal = e.target.closest('[role="dialog"]'); // Check if click is inside any modal
|
| 623 |
+
const clickedInsideDetails = e.target.closest('details'); // Don't clear if clicking inside the alias details
|
| 624 |
+
|
| 625 |
+
if (!clickedOnRow && !clickedOnScrollbar && !clickedOnActionButton && !clickedOnModal && !clickedInsideDetails) {
|
| 626 |
+
if (treeRef.current?.deselectAll) {
|
| 627 |
+
treeRef.current.deselectAll();
|
| 628 |
+
}
|
| 629 |
+
if (typeof setSelectedNodes === 'function') {
|
| 630 |
+
setSelectedNodes([]);
|
| 631 |
+
}
|
| 632 |
+
// Also clear the highlight on background click
|
| 633 |
+
setNewlyCreatedItemId(null);
|
| 634 |
+
}
|
| 635 |
+
}, [isDeleteModalOpen, isCreateFolderModalOpen, isDuplicateModalOpen]); // Dependencies to re-create handler if modal state changes
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
const actionButtonClass = "flex items-center gap-2 px-3 py-1.5 rounded bg-gray-700 text-gray-200 text-sm transition-colors duration-200 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed";
|
| 639 |
+
|
| 640 |
+
// Define scroll speeds
|
| 641 |
+
const slowScrollDuration = 30; // seconds
|
| 642 |
+
const fastScrollDuration = 10; // seconds
|
| 643 |
+
|
| 644 |
+
// Determine current scroll duration based on selection
|
| 645 |
+
const currentScrollDuration = selectedNodes.length > 0 ? fastScrollDuration : slowScrollDuration;
|
| 646 |
+
|
| 647 |
+
// --- Render ---
|
| 648 |
+
return (
|
| 649 |
+
<div className="flex flex-col h-full relative" onMouseDown={handleBackgroundClick}>
|
| 650 |
+
{/* Animated Background - pass dynamic duration */}
|
| 651 |
+
<ScrollingTextBackground duration={currentScrollDuration} />
|
| 652 |
+
|
| 653 |
+
{/* Main Content Area (Wrapped and Centered) */}
|
| 654 |
+
<div className="relative z-10 flex-grow flex flex-col max-w-7xl w-full mx-auto p-6">
|
| 655 |
+
|
| 656 |
+
{/* Header */}
|
| 657 |
+
<div className="flex justify-between items-center mb-4 bg-gray-900 bg-opacity-80 backdrop-blur-sm p-4 rounded-t-lg border-b border-gray-700">
|
| 658 |
+
<span>
|
| 659 |
+
<h1 className="text-2xl font-bold text-gray-100">Configuration Manager</h1>
|
| 660 |
+
<h2 className="text-lg text-gray-300 mt-1 animate-pulse-text-subtle">Select a config file to get started</h2>
|
| 661 |
+
</span>
|
| 662 |
+
<div className="flex items-center gap-2">
|
| 663 |
+
{/* EnhancedButton for Create Folder */}
|
| 664 |
+
<EnhancedButton
|
| 665 |
+
onClick={openCreateFolderModal}
|
| 666 |
+
className={`${actionButtonClass} hover:bg-green-600 ${selectedNodes.length > 0 ? 'opacity-75' : ''}`} // Keep existing hover for color change
|
| 667 |
+
title="Create new folder in selected location or root"
|
| 668 |
+
data-action-button="true"
|
| 669 |
+
particleColor="#22c55e" // Green particles
|
| 670 |
+
>
|
| 671 |
+
<FiFolderPlus /> Create Folder
|
| 672 |
+
</EnhancedButton>
|
| 673 |
+
{/* EnhancedButton for Duplicate Config */}
|
| 674 |
+
<EnhancedButton
|
| 675 |
+
onClick={openDuplicateModal}
|
| 676 |
+
className={`${actionButtonClass} hover:bg-blue-600 ${selectedNodes.length > 0 ? 'opacity-75' : ''}`} // Keep existing hover for color change
|
| 677 |
+
title="Duplicate a config from a source alias"
|
| 678 |
+
data-action-button="true"
|
| 679 |
+
particleColor="#3b82f6" // Blue particles
|
| 680 |
+
>
|
| 681 |
+
<FiCopy /> Duplicate Config
|
| 682 |
+
</EnhancedButton>
|
| 683 |
+
{/* Container for Delete button and hint - positioned relatively */}
|
| 684 |
+
<div className="relative">
|
| 685 |
+
{/* Conditionally show "double click" hint - positioned absolutely */}
|
| 686 |
+
{selectedNodes.length === 1 && !selectedNodes[0].is_dir && (
|
| 687 |
+
<p className="absolute bottom-full right-0 mb-1 text-xs italic text-gray-200 whitespace-nowrap">
|
| 688 |
+
or double click to open
|
| 689 |
+
</p>
|
| 690 |
+
)}
|
| 691 |
+
{/* EnhancedButton for Delete */}
|
| 692 |
+
<EnhancedButton
|
| 693 |
+
onClick={openDeleteModal}
|
| 694 |
+
className={`${actionButtonClass} ${selectedNodes.length > 0
|
| 695 |
+
? 'animate-pulse-red' // Keep existing pulse when selected
|
| 696 |
+
: 'text-red-400 hover:bg-red-900 hover:text-red-300' // Default styles
|
| 697 |
+
} ${isShaking ? 'animate-shake' : ''}`} // RE-ADD shake animation class
|
| 698 |
+
title="Delete Selected File(s) and/or Folder(s)"
|
| 699 |
+
disabled={selectedNodes.length === 0} // Still disabled when nothing selected
|
| 700 |
+
data-action-button="true"
|
| 701 |
+
particleColor="#ef4444" // Red particles
|
| 702 |
+
>
|
| 703 |
+
<FiTrash2 /> Delete
|
| 704 |
+
</EnhancedButton>
|
| 705 |
+
</div>
|
| 706 |
+
</div>
|
| 707 |
+
</div>
|
| 708 |
+
|
| 709 |
+
{/* Loading/Error States for Initial Load - Apply background */}
|
| 710 |
+
{isLoading && (
|
| 711 |
+
<div className="flex-grow flex items-center justify-center text-gray-400 bg-gray-900 bg-opacity-80 backdrop-blur-sm p-4 rounded-b-lg">
|
| 712 |
+
<FiLoader className="animate-spin mr-3" /> Loading configurations...
|
| 713 |
+
</div>
|
| 714 |
+
)}
|
| 715 |
+
{!isLoading && error && (
|
| 716 |
+
<div className="flex-grow flex flex-col items-center justify-center text-red-400 bg-red-900 bg-opacity-60 backdrop-blur-sm p-6 rounded-b-lg border border-red-700">
|
| 717 |
+
<FiAlertTriangle className="w-10 h-10 mb-2" />
|
| 718 |
+
<p>Error loading configuration structure:</p>
|
| 719 |
+
<p className="text-sm mt-1">{error}</p>
|
| 720 |
+
{/* EnhancedButton for Retry */}
|
| 721 |
+
<EnhancedButton onClick={fetchInitialData} className="mt-4 px-3 py-1.5 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 text-sm" particleColor="#9ca3af">
|
| 722 |
+
Retry
|
| 723 |
+
</EnhancedButton>
|
| 724 |
+
</div>
|
| 725 |
+
)}
|
| 726 |
+
|
| 727 |
+
{/* Tree View Area - Apply background */}
|
| 728 |
+
{!isLoading && !error && (
|
| 729 |
+
<div className="flex-grow border-b border-r border-l border-gray-700 rounded-b-lg bg-gray-900 bg-opacity-80 backdrop-blur-sm overflow-auto relative min-h-0"> {/* Ensure flex-grow and min-height */}
|
| 730 |
+
{(treeData && treeData.length > 0) ? (
|
| 731 |
+
<Tree
|
| 732 |
+
ref={treeRef}
|
| 733 |
+
data={treeData}
|
| 734 |
+
openByDefault={false}
|
| 735 |
+
width="100%"
|
| 736 |
+
height={1000} // Reverted to a large number, container handles scrolling
|
| 737 |
+
indent={24}
|
| 738 |
+
rowHeight={32}
|
| 739 |
+
paddingTop={10}
|
| 740 |
+
paddingBottom={10}
|
| 741 |
+
onSelect={handleSelect}
|
| 742 |
+
onToggle={handleToggle}
|
| 743 |
+
onMove={handleMove}
|
| 744 |
+
disableDrop={({ parentNode, dropTarget }) => {
|
| 745 |
+
// Prevent dropping *onto* a file
|
| 746 |
+
return dropTarget && !dropTarget.data.is_dir;
|
| 747 |
+
}}
|
| 748 |
+
// Selection follows focus can be useful for multi-select + drag
|
| 749 |
+
// selectionFollowsFocus={true}
|
| 750 |
+
>
|
| 751 |
+
{/* Pass props needed by Node, including click tracking and newly created ID */}
|
| 752 |
+
{(props) => <Node {...props} navigate={navigate} onNodeClick={handleNodeClick} lastClick={lastClick} newlyCreatedItemId={newlyCreatedItemId} />}
|
| 753 |
+
</Tree>
|
| 754 |
+
) : (
|
| 755 |
+
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
| 756 |
+
<p>No configuration files or folders found in ./external_configs/</p>
|
| 757 |
+
{/* Optional: Add buttons to create folder/duplicate even when empty */}
|
| 758 |
+
</div>
|
| 759 |
+
)}
|
| 760 |
+
</div>
|
| 761 |
+
)}
|
| 762 |
+
|
| 763 |
+
{/* Status Bar Area - Apply background */}
|
| 764 |
+
<div className="h-8 mt-2 text-sm flex items-center gap-4 overflow-hidden whitespace-nowrap px-4 py-1 bg-gray-900 bg-opacity-80 backdrop-blur-sm rounded-lg border border-gray-700">
|
| 765 |
+
{/* Moving Status */}
|
| 766 |
+
{isMoving && (
|
| 767 |
+
<div className="flex items-center text-blue-400">
|
| 768 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> Moving items...
|
| 769 |
+
</div>
|
| 770 |
+
)}
|
| 771 |
+
{moveError && <p className="text-red-400 truncate" title={moveError}>Move Error: {moveError}</p>}
|
| 772 |
+
|
| 773 |
+
{/* General Action Status (Delete, Create, Duplicate) */}
|
| 774 |
+
{actionStatus.message && (
|
| 775 |
+
<p className={`${actionStatus.error ? 'text-red-400' : actionStatus.success ? 'text-green-400' : 'text-gray-400'} truncate flex items-center`} title={actionStatus.message}>
|
| 776 |
+
{actionStatus.success && <FiCheckCircle className="mr-1 flex-shrink-0"/>}
|
| 777 |
+
{actionStatus.error && <FiAlertTriangle className="mr-1 flex-shrink-0"/>}
|
| 778 |
+
{actionStatus.message}
|
| 779 |
+
</p>
|
| 780 |
+
)}
|
| 781 |
+
</div>
|
| 782 |
+
|
| 783 |
+
</div> {/* End Main Content Wrapper */}
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
{/* --- Modals --- */}
|
| 787 |
+
{/* Modals remain outside the centered content, possibly covering the whole screen */}
|
| 788 |
+
|
| 789 |
+
{/* Delete Confirmation Modal */}
|
| 790 |
+
<Modal
|
| 791 |
+
isOpen={isDeleteModalOpen}
|
| 792 |
+
onClose={() => !isSubmittingModal && setIsDeleteModalOpen(false)}
|
| 793 |
+
title="Confirm Deletion"
|
| 794 |
+
>
|
| 795 |
+
<p className="mb-4">Are you sure you want to delete the following {selectedNodes.length} item(s) (files and folders)? This action cannot be undone.</p>
|
| 796 |
+
<ul className="list-disc list-inside mb-4 max-h-40 overflow-y-auto bg-gray-700 p-2 rounded border border-gray-600">
|
| 797 |
+
{/* Show all selected items, files and folders */}
|
| 798 |
+
{selectedNodes.map(node => <li key={node.id} className="truncate text-sm py-0.5 flex items-center gap-1" title={node.relativePath}>
|
| 799 |
+
{node.is_dir ? <FiFolder className="inline-block w-4 h-4 text-yellow-500 flex-shrink-0" /> : <FiFile className="inline-block w-4 h-4 text-blue-400 flex-shrink-0" />}
|
| 800 |
+
<span>{node.relativePath}</span>
|
| 801 |
+
</li>)}
|
| 802 |
+
</ul>
|
| 803 |
+
{modalError && (
|
| 804 |
+
<p className="text-red-400 text-sm mb-3 bg-red-900 bg-opacity-40 p-2 rounded border border-red-700">{modalError}</p>
|
| 805 |
+
)}
|
| 806 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 807 |
+
{/* Standard button for Cancel */}
|
| 808 |
+
<button
|
| 809 |
+
type="button"
|
| 810 |
+
onClick={() => setIsDeleteModalOpen(false)}
|
| 811 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 transition-colors disabled:opacity-50"
|
| 812 |
+
disabled={isSubmittingModal}
|
| 813 |
+
>
|
| 814 |
+
Cancel
|
| 815 |
+
</button>
|
| 816 |
+
{/* EnhancedButton for Confirm Delete */}
|
| 817 |
+
<EnhancedButton
|
| 818 |
+
onClick={handleDelete}
|
| 819 |
+
className={`px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white flex items-center disabled:opacity-50
|
| 820 |
+
${isDeleteModalOpen && !isSubmittingModal && selectedNodes.length > 0 ? 'animate-pulse-red' : ''}`}
|
| 821 |
+
disabled={isSubmittingModal || selectedNodes.length === 0} // Disable only if nothing selected or submitting
|
| 822 |
+
particleColor="#f87171" // Light red particles to contrast with bg
|
| 823 |
+
>
|
| 824 |
+
{isSubmittingModal ? <FiLoader className="animate-spin mr-2" /> : <FiTrash2 className="mr-1" />}
|
| 825 |
+
{isSubmittingModal ? 'Deleting...' : `Delete ${selectedNodes.length} Item(s)`}
|
| 826 |
+
</EnhancedButton>
|
| 827 |
+
</div>
|
| 828 |
+
</Modal>
|
| 829 |
+
|
| 830 |
+
{/* Create Folder Modal */}
|
| 831 |
+
<Modal
|
| 832 |
+
isOpen={isCreateFolderModalOpen}
|
| 833 |
+
onClose={() => !isSubmittingModal && setIsCreateFolderModalOpen(false)}
|
| 834 |
+
title="Create New Folder"
|
| 835 |
+
>
|
| 836 |
+
<label htmlFor="newFolderName" className="block mb-2 text-sm font-medium text-gray-300">Folder Name:</label>
|
| 837 |
+
<input
|
| 838 |
+
ref={createFolderInputRef}
|
| 839 |
+
id="newFolderName"
|
| 840 |
+
type="text"
|
| 841 |
+
value={newFolderName}
|
| 842 |
+
onChange={(e) => setNewFolderName(e.target.value)}
|
| 843 |
+
placeholder="e.g., my_configs or nested/folder"
|
| 844 |
+
className={`w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 mb-1
|
| 845 |
+
${!newFolderName.trim() && isCreateFolderModalOpen ? 'animate-pulse-bg-subtle' : ''}`}
|
| 846 |
+
disabled={isSubmittingModal}
|
| 847 |
+
onKeyDown={(e) => e.key === 'Enter' && !isSubmittingModal && newFolderName.trim() && handleCreateFolder()}
|
| 848 |
+
/>
|
| 849 |
+
<p className="text-xs text-gray-400 mb-3">Folder will be created inside the selected folder, or at the root if none is selected.</p>
|
| 850 |
+
{modalError && <p className="text-red-400 text-xs mt-1 mb-3">Error: {modalError}</p>}
|
| 851 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 852 |
+
{/* Standard button for Cancel */}
|
| 853 |
+
<button
|
| 854 |
+
type="button"
|
| 855 |
+
onClick={() => setIsCreateFolderModalOpen(false)}
|
| 856 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 transition-colors disabled:opacity-50"
|
| 857 |
+
disabled={isSubmittingModal}
|
| 858 |
+
>
|
| 859 |
+
Cancel
|
| 860 |
+
</button>
|
| 861 |
+
{/* EnhancedButton for Confirm Create */}
|
| 862 |
+
<EnhancedButton
|
| 863 |
+
onClick={handleCreateFolder}
|
| 864 |
+
className={`px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white flex items-center disabled:opacity-50
|
| 865 |
+
${newFolderName.trim() && !isSubmittingModal ? 'animate-pulse-blue-gold' : ''}`}
|
| 866 |
+
disabled={!newFolderName.trim() || isSubmittingModal}
|
| 867 |
+
particleColor="#60a5fa" // Light blue particles
|
| 868 |
+
>
|
| 869 |
+
{isSubmittingModal ? <FiLoader className="animate-spin mr-2" /> : <FiFolderPlus className="mr-1" />}
|
| 870 |
+
{isSubmittingModal ? 'Creating...' : 'Create'}
|
| 871 |
+
</EnhancedButton>
|
| 872 |
+
</div>
|
| 873 |
+
</Modal>
|
| 874 |
+
|
| 875 |
+
{/* Duplicate Config Modal */}
|
| 876 |
+
<Modal
|
| 877 |
+
isOpen={isDuplicateModalOpen}
|
| 878 |
+
onClose={() => !isSubmittingModal && setIsDuplicateModalOpen(false)}
|
| 879 |
+
title="Duplicate Configuration"
|
| 880 |
+
>
|
| 881 |
+
{/* Source Alias Input */}
|
| 882 |
+
<div className="mb-4 relative">
|
| 883 |
+
<label htmlFor="duplicateSourceAlias" className="block mb-1 text-sm font-medium text-gray-300">Source Alias:</label>
|
| 884 |
+
<input
|
| 885 |
+
ref={aliasInputRef}
|
| 886 |
+
id="duplicateSourceAlias"
|
| 887 |
+
type="text"
|
| 888 |
+
value={duplicateSourceAlias}
|
| 889 |
+
onChange={handleAliasInputChange}
|
| 890 |
+
placeholder="Type to search or select from list below..."
|
| 891 |
+
className={`w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
| 892 |
+
${!duplicateSourceAlias.trim() && !isAliasDetailsOpen && isDuplicateModalOpen ? 'animate-pulse-bg-subtle' : ''}`}
|
| 893 |
+
disabled={isSubmittingModal || isFetchingAliases}
|
| 894 |
+
/>
|
| 895 |
+
{isFetchingAliases && <FiLoader className="animate-spin absolute right-3 top-9 transform text-gray-400"/>}
|
| 896 |
+
{/* Suggestions Dropdown */}
|
| 897 |
+
{aliasSuggestions.length > 0 && (
|
| 898 |
+
<ul className="absolute z-10 w-full mt-1 bg-gray-700 border border-gray-600 rounded shadow-lg max-h-40 overflow-y-auto">
|
| 899 |
+
{aliasSuggestions.map((alias) => (
|
| 900 |
+
<li
|
| 901 |
+
key={alias}
|
| 902 |
+
className="px-3 py-2 cursor-pointer hover:bg-gray-600 text-gray-200 text-sm"
|
| 903 |
+
onClick={() => handleAliasSuggestionClick(alias)}
|
| 904 |
+
>
|
| 905 |
+
{alias}
|
| 906 |
+
</li>
|
| 907 |
+
))}
|
| 908 |
+
</ul>
|
| 909 |
+
)}
|
| 910 |
+
{!isFetchingAliases && availableAliases.length === 0 && !modalError && !isFetchingAliases && (
|
| 911 |
+
<p className="text-xs text-yellow-400 mt-1">No config aliases found or failed to load.</p>
|
| 912 |
+
)}
|
| 913 |
+
</div>
|
| 914 |
+
|
| 915 |
+
{/* Expandable List of All Aliases */}
|
| 916 |
+
{!isFetchingAliases && availableAliases.length > 0 && (
|
| 917 |
+
<details
|
| 918 |
+
className={`mb-4 rounded border border-gray-600 overflow-hidden
|
| 919 |
+
${isAliasDetailsOpen && !duplicateSourceAlias.trim() ? 'animate-pulse-border-subtle' : ''}`}
|
| 920 |
+
onToggle={(e) => setIsAliasDetailsOpen(e.target.open)} // Track open state
|
| 921 |
+
>
|
| 922 |
+
<summary className="px-3 py-2 cursor-pointer bg-gray-700 hover:bg-gray-650 text-sm text-gray-300 flex justify-between items-center">
|
| 923 |
+
<span>Show All Available Aliases ({availableAliases.length})</span>
|
| 924 |
+
{/* Simple triangle indicator */}
|
| 925 |
+
<span className="details-marker">▼</span>
|
| 926 |
+
</summary>
|
| 927 |
+
<ul className="bg-gray-800 max-h-48 overflow-y-auto p-2">
|
| 928 |
+
{availableAliases
|
| 929 |
+
.slice() // Create a copy to avoid modifying the original state
|
| 930 |
+
.sort((a, b) => a.localeCompare(b)) // Sort alphabetically
|
| 931 |
+
.map((alias) => (
|
| 932 |
+
<li
|
| 933 |
+
key={alias}
|
| 934 |
+
className="px-3 py-1.5 cursor-pointer hover:bg-gray-700 text-gray-200 text-sm rounded"
|
| 935 |
+
onClick={(e) => {
|
| 936 |
+
handleAliasSuggestionClick(alias);
|
| 937 |
+
// Optionally close the details element after selection
|
| 938 |
+
e.target.closest('details')?.removeAttribute('open');
|
| 939 |
+
}}
|
| 940 |
+
>
|
| 941 |
+
{alias}
|
| 942 |
+
</li>
|
| 943 |
+
))}
|
| 944 |
+
</ul>
|
| 945 |
+
</details>
|
| 946 |
+
)}
|
| 947 |
+
{/* CSS for the details marker - could be added to a global CSS file instead */}
|
| 948 |
+
<style>{`
|
| 949 |
+
details > summary { list-style: none; }
|
| 950 |
+
details > summary::-webkit-details-marker { display: none; }
|
| 951 |
+
details > summary .details-marker { transition: transform 0.2s; }
|
| 952 |
+
details[open] > summary .details-marker { transform: rotate(180deg); }
|
| 953 |
+
`}</style>
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
{/* Destination Path Input */}
|
| 957 |
+
<div className="mb-4">
|
| 958 |
+
<label htmlFor="duplicateDestPath" className="block mb-1 text-sm font-medium text-gray-300">Destination Path:</label>
|
| 959 |
+
<input
|
| 960 |
+
id="duplicateDestPath"
|
| 961 |
+
type="text"
|
| 962 |
+
value={duplicateDestPath}
|
| 963 |
+
onChange={(e) => setDuplicateDestPath(e.target.value)}
|
| 964 |
+
placeholder="e.g., new_config.yaml or folder/new_config.yaml"
|
| 965 |
+
className={`w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
| 966 |
+
${duplicateSourceAlias.trim() && !duplicateDestPath.trim() && isDuplicateModalOpen ? 'animate-pulse-bg-subtle' : ''}`}
|
| 967 |
+
disabled={isSubmittingModal}
|
| 968 |
+
/>
|
| 969 |
+
<p className="text-xs text-gray-400 mt-1">Path relative to <code className="text-xs bg-gray-600 px-1 rounded">./external_configs/</code>. Must end in
|
| 970 |
+
<code className={`text-xs bg-gray-600 px-1 rounded ml-1
|
| 971 |
+
${duplicateDestPath.trim() && !duplicateDestPath.trim().toLowerCase().endsWith('.yaml') ? 'text-red-400 font-bold' : ''}
|
| 972 |
+
`}>
|
| 973 |
+
.yaml
|
| 974 |
+
</code>.
|
| 975 |
+
</p>
|
| 976 |
+
</div>
|
| 977 |
+
|
| 978 |
+
{/* Modal Error Display */}
|
| 979 |
+
{modalError && <p className="text-red-400 text-sm mb-3 bg-red-900 bg-opacity-40 p-2 rounded border border-red-700">{modalError}</p>}
|
| 980 |
+
|
| 981 |
+
{/* Modal Actions */}
|
| 982 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 983 |
+
{/* Standard button for Cancel */}
|
| 984 |
+
<button
|
| 985 |
+
type="button"
|
| 986 |
+
onClick={() => setIsDuplicateModalOpen(false)}
|
| 987 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 transition-colors disabled:opacity-50"
|
| 988 |
+
disabled={isSubmittingModal}
|
| 989 |
+
>
|
| 990 |
+
Cancel
|
| 991 |
+
</button>
|
| 992 |
+
{/* EnhancedButton for Confirm Duplicate */}
|
| 993 |
+
<EnhancedButton
|
| 994 |
+
onClick={handleDuplicate}
|
| 995 |
+
className={`px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white flex items-center disabled:opacity-50
|
| 996 |
+
${duplicateSourceAlias.trim() && duplicateDestPath.trim().toLowerCase().endsWith('.yaml') && !isSubmittingModal ? 'animate-pulse-blue-gold' : ''}
|
| 997 |
+
`}
|
| 998 |
+
disabled={!duplicateSourceAlias.trim() || !duplicateDestPath.trim().toLowerCase().endsWith('.yaml') || isSubmittingModal || isFetchingAliases}
|
| 999 |
+
particleColor="#60a5fa" // Light blue particles
|
| 1000 |
+
>
|
| 1001 |
+
{isSubmittingModal ? <FiLoader className="animate-spin mr-2" /> : <FiCopy className="mr-1" />}
|
| 1002 |
+
{isSubmittingModal ? 'Duplicating...' : 'Duplicate'}
|
| 1003 |
+
</EnhancedButton>
|
| 1004 |
+
</div>
|
| 1005 |
+
</Modal>
|
| 1006 |
+
|
| 1007 |
+
</div> // End main div
|
| 1008 |
+
);
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
export default ConfigSelector;
|
atk-interface/src/pages/HomePage.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
+
import { useNavigation } from '../context/NavigationContext';
|
| 3 |
+
|
| 4 |
+
function HomePage() {
|
| 5 |
+
const { setHighlightedNavLink } = useNavigation();
|
| 6 |
+
|
| 7 |
+
useEffect(() => {
|
| 8 |
+
setHighlightedNavLink('/configs'); // highlight configs when mounted
|
| 9 |
+
return () => setHighlightedNavLink(null); // clear on unmount
|
| 10 |
+
}, [setHighlightedNavLink]);
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<div className="p-8 flex flex-col items-center justify-center h-full">
|
| 14 |
+
<h1 className="text-4xl font-bold mb-4">Welcome to Augmentoolkit</h1>
|
| 15 |
+
<p className="text-xl text-gray-400">Select a section from the navigation above to get started.</p>
|
| 16 |
+
{/* Placeholder for logo or other content */}
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default HomePage;
|
atk-interface/src/pages/InputManager.jsx
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import { Tree } from 'react-arborist';
|
| 3 |
+
import { FiFile, FiFolder, FiFolderPlus, FiUploadCloud, FiDownloadCloud, FiTrash2, FiLoader, FiAlertTriangle, FiCheckCircle, FiChevronDown, FiChevronRight } from 'react-icons/fi';
|
| 4 |
+
import { useDropzone } from 'react-dropzone';
|
| 5 |
+
import { uploadInputFiles, downloadInputItem, deleteInputItem, createInputDirectory, moveInputItem } from '../api';
|
| 6 |
+
import { useFileManager } from '../context/FileManagerContext';
|
| 7 |
+
import Modal from '../components/Modal';
|
| 8 |
+
import JSZip from 'jszip';
|
| 9 |
+
import PourOverBackground from '../components/PourOverBackground';
|
| 10 |
+
import EnhancedButton from '../components/EnhancedButton';
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* @typedef {object} InputNodeData
|
| 14 |
+
* @property {string} id - Unique ID (can be the relative path)
|
| 15 |
+
* @property {string} name - Display name (basename of the path)
|
| 16 |
+
* @property {boolean} [isInternal] - Flag for placeholder nodes
|
| 17 |
+
* @property {string} relativePath - Store the full relative path
|
| 18 |
+
* @property {boolean} is_dir - Keep the directory flag
|
| 19 |
+
* @property {InputNodeData[] | null} [children] - Children array or null if leaf
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
/** @param {import("react-arborist").NodeRendererProps<InputNodeData> & { onNodeClick: Function, lastClick: { id: string | null, time: number }, newlyCreatedItemId: string | null }} props */
|
| 23 |
+
function Node({ node, style, dragHandle, onNodeClick, lastClick, newlyCreatedItemId }) {
|
| 24 |
+
const Icon = node.isLeaf ? FiFile : FiFolder;
|
| 25 |
+
const iconColor = node.isLeaf ? 'text-blue-400' : 'text-yellow-500';
|
| 26 |
+
const isNewlyCreated = node.id === newlyCreatedItemId;
|
| 27 |
+
|
| 28 |
+
// Optional: add row‑click to select *and* open/close
|
| 29 |
+
const handleRowClick = (e) => {
|
| 30 |
+
const now = Date.now();
|
| 31 |
+
const DOUBLE_CLICK_THRESHOLD = 300; // ms
|
| 32 |
+
|
| 33 |
+
if (lastClick.id === node.id && (now - lastClick.time) < DOUBLE_CLICK_THRESHOLD) {
|
| 34 |
+
// Double click detected
|
| 35 |
+
if (node.isInternal) {
|
| 36 |
+
node.toggle(); // Toggle folder open/closed state
|
| 37 |
+
}
|
| 38 |
+
// Reset last click to prevent triple click issues
|
| 39 |
+
onNodeClick(null, 0);
|
| 40 |
+
} else {
|
| 41 |
+
// Single click
|
| 42 |
+
node.handleClick(e); // Let react-arborist handle selection
|
| 43 |
+
onNodeClick(node.id, now); // Update last click state
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Optional: only the caret toggles
|
| 48 |
+
const handleCaretClick = (e) => {
|
| 49 |
+
e.stopPropagation(); // keep the row‑click logic independent
|
| 50 |
+
node.toggle();
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div
|
| 55 |
+
ref={dragHandle}
|
| 56 |
+
style={style}
|
| 57 |
+
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer
|
| 58 |
+
${node.isSelected ? 'bg-blue-700' : 'hover:bg-gray-700'}
|
| 59 |
+
${node.isEditing ? 'bg-gray-600' : ''}
|
| 60 |
+
${isNewlyCreated ? 'animate-pulse-white' : ''}`}
|
| 61 |
+
onClick={handleRowClick} // Use single click handler for selection/toggle
|
| 62 |
+
>
|
| 63 |
+
{/* caret */}
|
| 64 |
+
<span
|
| 65 |
+
className="w-6 text-center flex items-center justify-center"
|
| 66 |
+
onClick={handleCaretClick} /* <-- give it life */
|
| 67 |
+
>
|
| 68 |
+
{node.isInternal && (node.isOpen ? <FiChevronDown /> : <FiChevronRight />)}
|
| 69 |
+
</span>
|
| 70 |
+
|
| 71 |
+
{/* icon + name */}
|
| 72 |
+
<Icon className={`${iconColor} flex-shrink-0 mr-1`} />
|
| 73 |
+
<span
|
| 74 |
+
className="truncate flex-grow text-gray-200"
|
| 75 |
+
>
|
| 76 |
+
{node.data.name}
|
| 77 |
+
</span>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
function InputManager() {
|
| 84 |
+
const { inputTreeData: treeData, isInputLoading: isLoading, inputError: error, fetchInputData, refreshInputData } = useFileManager();
|
| 85 |
+
/** @type {[InputNodeData[], Function]} */
|
| 86 |
+
const [selectedNodes, setSelectedNodes] = useState([]);
|
| 87 |
+
const treeRef = useRef(null);
|
| 88 |
+
console.log("InputManager RENDERED"); // Log component render
|
| 89 |
+
// State for manual double-click tracking
|
| 90 |
+
const [lastClick, setLastClick] = useState({ id: null, time: 0 });
|
| 91 |
+
// State for newly created item highlight
|
| 92 |
+
const [newlyCreatedItemId, setNewlyCreatedItemId] = useState(null);
|
| 93 |
+
// State to store names of items from the last upload for highlighting
|
| 94 |
+
const [lastUploadedNames, setLastUploadedNames] = useState([]);
|
| 95 |
+
// State to trigger the search effect after an upload cycle completes
|
| 96 |
+
const [uploadCycle, setUploadCycle] = useState(0);
|
| 97 |
+
|
| 98 |
+
// Modals State
|
| 99 |
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
| 100 |
+
const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false);
|
| 101 |
+
const [newFolderName, setNewFolderName] = useState('');
|
| 102 |
+
const [modalError, setModalError] = useState(null);
|
| 103 |
+
const [isSubmittingModal, setIsSubmittingModal] = useState(false);
|
| 104 |
+
|
| 105 |
+
// Upload Feedback State
|
| 106 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 107 |
+
const [uploadProgress, setUploadProgress] = useState(0);
|
| 108 |
+
const [uploadError, setUploadError] = useState(null);
|
| 109 |
+
const [lastUploadResult, setLastUploadResult] = useState(null);
|
| 110 |
+
|
| 111 |
+
// Download state
|
| 112 |
+
const [isDownloading, setIsDownloading] = useState(false);
|
| 113 |
+
const [downloadStatus, setDownloadStatus] = useState({ message: '', error: false });
|
| 114 |
+
|
| 115 |
+
// Move/Drag State
|
| 116 |
+
const [isMoving, setIsMoving] = useState(false);
|
| 117 |
+
const [moveError, setMoveError] = useState(null);
|
| 118 |
+
|
| 119 |
+
// State for shake animation trigger
|
| 120 |
+
const [isShaking, setIsShaking] = useState(false);
|
| 121 |
+
|
| 122 |
+
// --- State for PourOverBackground ---
|
| 123 |
+
const [bgIsActive, setBgIsActive] = useState(false);
|
| 124 |
+
const [triggerBgDownload, setTriggerBgDownload] = useState(false);
|
| 125 |
+
const [triggerBgDelete, setTriggerBgDelete] = useState(false);
|
| 126 |
+
|
| 127 |
+
// Refs for modal inputs
|
| 128 |
+
const createFolderInputRef = useRef(null);
|
| 129 |
+
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
fetchInputData(); // Fetch data on mount (uses cache if available)
|
| 132 |
+
}, [fetchInputData]);
|
| 133 |
+
|
| 134 |
+
// --- Handlers for PourOverBackground Callbacks ---
|
| 135 |
+
const handleDownloadAnimComplete = useCallback(() => {
|
| 136 |
+
setTriggerBgDownload(false);
|
| 137 |
+
console.log("LOG: handleDownloadAnimComplete called");
|
| 138 |
+
}, []);
|
| 139 |
+
|
| 140 |
+
const handleDeleteAnimComplete = useCallback(() => {
|
| 141 |
+
setTriggerBgDelete(false);
|
| 142 |
+
console.log("LOG: handleDeleteAnimComplete called");
|
| 143 |
+
}, []);
|
| 144 |
+
|
| 145 |
+
// Effect to trigger shake animation on selection change
|
| 146 |
+
useEffect(() => {
|
| 147 |
+
if (selectedNodes.length > 0) {
|
| 148 |
+
setIsShaking(true);
|
| 149 |
+
const timer = setTimeout(() => {
|
| 150 |
+
setIsShaking(false);
|
| 151 |
+
}, 400); // Duration of the shake animation
|
| 152 |
+
return () => clearTimeout(timer); // Cleanup timer on unmount or if selection changes again quickly
|
| 153 |
+
} else {
|
| 154 |
+
setIsShaking(false); // Ensure shaking stops if selection is cleared
|
| 155 |
+
}
|
| 156 |
+
}, [selectedNodes.length]); // Depend only on the length changing
|
| 157 |
+
|
| 158 |
+
// Effect to handle newly created items: expand parents and scroll into view
|
| 159 |
+
useEffect(() => {
|
| 160 |
+
console.log(`LOG: Highlight EFFECT triggered. newlyCreatedItemId: ${newlyCreatedItemId}, treeData available: ${!!treeData}`);
|
| 161 |
+
if (newlyCreatedItemId && treeRef.current && treeData) {
|
| 162 |
+
console.log(`LOG: Highlight EFFECT processing ID: ${newlyCreatedItemId}`);
|
| 163 |
+
const node = treeRef.current.get(newlyCreatedItemId);
|
| 164 |
+
console.log(`LOG: Highlight EFFECT node found: ${!!node}`);
|
| 165 |
+
if (node) {
|
| 166 |
+
// Expand all parents
|
| 167 |
+
let parent = node.parent;
|
| 168 |
+
console.log("LOG: Highlight EFFECT expanding parents...");
|
| 169 |
+
while (parent && !parent.isOpen) {
|
| 170 |
+
console.log(`LOG: Highlight EFFECT opening parent: ${parent.id}`);
|
| 171 |
+
if (parent.id) {
|
| 172 |
+
treeRef.current.open(parent.id, { autoSelect: false });
|
| 173 |
+
} else {
|
| 174 |
+
console.warn("Could not open parent, ID missing:", parent);
|
| 175 |
+
break;
|
| 176 |
+
}
|
| 177 |
+
parent = parent.parent; // Move up the tree
|
| 178 |
+
}
|
| 179 |
+
// Scroll the new node into view
|
| 180 |
+
const timerId = setTimeout(() => {
|
| 181 |
+
treeRef.current?.scrollTo(newlyCreatedItemId, "center");
|
| 182 |
+
console.log(`LOG: Highlight EFFECT scrolled to ${newlyCreatedItemId}`);
|
| 183 |
+
}, 100);
|
| 184 |
+
return () => clearTimeout(timerId);
|
| 185 |
+
} else {
|
| 186 |
+
console.warn(`Node with ID ${newlyCreatedItemId} not found in tree after creation/upload.`);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}, [newlyCreatedItemId, treeData]); // Rerun when ID changes or tree data updates
|
| 190 |
+
|
| 191 |
+
// Effect to find and highlight the first newly uploaded item after data refresh
|
| 192 |
+
useEffect(() => {
|
| 193 |
+
console.log(`LOG: Uploaded item search EFFECT triggered. uploadCycle: ${uploadCycle}, lastUploadedNames: [${lastUploadedNames.join(', ')}], treeData available: ${!!treeData}`);
|
| 194 |
+
// Search the raw treeData, not just visible nodes
|
| 195 |
+
if (lastUploadedNames.length > 0 && Array.isArray(treeData)) {
|
| 196 |
+
let foundNodeId = null;
|
| 197 |
+
console.log("LOG: Uploaded item search EFFECT searching raw treeData...");
|
| 198 |
+
|
| 199 |
+
// Recursive function to search the raw data structure
|
| 200 |
+
const findNodeInData = (nodes) => {
|
| 201 |
+
if (!Array.isArray(nodes)) return null;
|
| 202 |
+
|
| 203 |
+
for (const node of nodes) {
|
| 204 |
+
// node here is the raw data object (e.g., { id: 'path', name: 'name', ...})
|
| 205 |
+
// console.log(`LOG: Searching... Checking node: ${node.name} (ID: ${node.id})`);
|
| 206 |
+
if (node.isdir && node.children) {
|
| 207 |
+
console.log(`LOG: Searching... Checking directory node: ${node.name} (ID: ${node.id})`); // only log dir checks because there are a LOT of files here
|
| 208 |
+
}
|
| 209 |
+
if (lastUploadedNames.includes(node.name)) {
|
| 210 |
+
console.log(`LOG: Uploaded item search EFFECT found match: ${node.name} (ID: ${node.id})`);
|
| 211 |
+
return node.id; // Return the ID (path) of the first match
|
| 212 |
+
}
|
| 213 |
+
// If it's a directory, recurse into its children
|
| 214 |
+
if (node.is_dir && node.children) {
|
| 215 |
+
const foundInChildren = findNodeInData(node.children);
|
| 216 |
+
if (foundInChildren) {
|
| 217 |
+
return foundInChildren;
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
return null;
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
// Start search from the root of treeData
|
| 225 |
+
foundNodeId = findNodeInData(treeData);
|
| 226 |
+
|
| 227 |
+
if (foundNodeId) {
|
| 228 |
+
console.log(`LOG: Uploaded item search EFFECT setting newlyCreatedItemId = ${foundNodeId}`);
|
| 229 |
+
setNewlyCreatedItemId(foundNodeId);
|
| 230 |
+
// Clear the names list ONLY after successfully finding and setting the highlight
|
| 231 |
+
console.log("LOG: Uploaded item search EFFECT clearing lastUploadedNames after success.");
|
| 232 |
+
setLastUploadedNames([]);
|
| 233 |
+
} else {
|
| 234 |
+
console.log("LOG: Uploaded item search EFFECT found no matching node in current treeData. Will retry if treeData updates.");
|
| 235 |
+
// Optional: Could implement a deeper search if needed, but start simple
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
}, [treeData, lastUploadedNames, uploadCycle]); // Depend on treeData, names, and the upload cycle trigger
|
| 239 |
+
|
| 240 |
+
const handleUpload = useCallback(async (fileList, targetPath) => {
|
| 241 |
+
const files = Array.from(fileList);
|
| 242 |
+
console.log(`LOG: handleUpload START. Target: ${targetPath}, Files:`, files);
|
| 243 |
+
|
| 244 |
+
// Check whether any item is a directory (webkitRelativePath !== '')
|
| 245 |
+
const containsDir = files.some(f => f.webkitRelativePath);
|
| 246 |
+
console.log(`LOG: handleUpload containsDir: ${containsDir}`);
|
| 247 |
+
|
| 248 |
+
let payload = files;
|
| 249 |
+
if (containsDir) {
|
| 250 |
+
console.log("LOG: handleUpload zipping directories...");
|
| 251 |
+
// zip each top‑level folder
|
| 252 |
+
const groups = {};
|
| 253 |
+
files.forEach(f => {
|
| 254 |
+
const [root] = f.webkitRelativePath.split('/');
|
| 255 |
+
(groups[root] ||= []).push(f);
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
const zips = await Promise.all(
|
| 259 |
+
Object.entries(groups).map(async ([folder, fs]) => {
|
| 260 |
+
const zip = new JSZip();
|
| 261 |
+
fs.forEach(f =>
|
| 262 |
+
zip.file(f.webkitRelativePath.replace(`${folder}/`, ''), f)
|
| 263 |
+
);
|
| 264 |
+
const blob = await zip.generateAsync({ type: 'blob' });
|
| 265 |
+
return new File([blob], `${folder}.zip`, { type: 'application/zip' });
|
| 266 |
+
})
|
| 267 |
+
);
|
| 268 |
+
payload = zips;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Trigger background activity briefly on upload? (Optional)
|
| 272 |
+
setBgIsActive(true);
|
| 273 |
+
setTimeout(() => setBgIsActive(false), 1000); // Example: active for 1 sec
|
| 274 |
+
|
| 275 |
+
console.log("LOG: handleUpload BEFORE uploadInputFiles call");
|
| 276 |
+
await uploadInputFiles(targetPath, payload);
|
| 277 |
+
console.log("LOG: handleUpload AFTER uploadInputFiles call");
|
| 278 |
+
// Highlight is now set in onDrop *after* this completes
|
| 279 |
+
// if (target !== '.') {
|
| 280 |
+
// setNewlyCreatedItemId(targetPath);
|
| 281 |
+
// }
|
| 282 |
+
console.log("LOG: handleUpload BEFORE refreshInputData call");
|
| 283 |
+
refreshInputData(); // Refresh data after upload
|
| 284 |
+
console.log("LOG: handleUpload AFTER refreshInputData call (refresh initiated)");
|
| 285 |
+
}, [refreshInputData]);
|
| 286 |
+
|
| 287 |
+
const handleSelect = (nodes) => {
|
| 288 |
+
const selectedData = nodes.map(n => n.data);
|
| 289 |
+
console.log("LOG: handleSelect called. Selected nodes count:", selectedData.length);
|
| 290 |
+
setSelectedNodes(selectedData);
|
| 291 |
+
// Set background to active if something is selected
|
| 292 |
+
setBgIsActive(selectedData.length > 0);
|
| 293 |
+
if (selectedData.length > 0) {
|
| 294 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleSelect.`);
|
| 295 |
+
setNewlyCreatedItemId(null); // Clear highlight on selection
|
| 296 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleSelect.`);
|
| 297 |
+
setLastUploadedNames([]); // Also clear search list
|
| 298 |
+
}
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
const handleToggle = (id) => {
|
| 302 |
+
console.log('LOG: handleToggle called. Toggled ID:', id);
|
| 303 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleToggle.`);
|
| 304 |
+
setNewlyCreatedItemId(null); // Clear highlight on toggle
|
| 305 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleToggle.`);
|
| 306 |
+
setLastUploadedNames([]); // Also clear search list
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
// --- Move Handler ---
|
| 310 |
+
/** @param {import("react-arborist").MoveHandler<InputNodeData>} args */
|
| 311 |
+
const handleMove = async ({ dragIds, parentId, index }) => {
|
| 312 |
+
setIsMoving(true);
|
| 313 |
+
setMoveError(null);
|
| 314 |
+
console.log("Move detected:", { dragIds, parentId, index });
|
| 315 |
+
|
| 316 |
+
const parentNode = parentId ? treeRef.current?.get(parentId) : null;
|
| 317 |
+
const parentPath = parentNode ? parentNode.data.relativePath : '.'; // Root is '.'
|
| 318 |
+
console.log("Parent Node:", parentNode);
|
| 319 |
+
console.log("Parent Path:", parentPath);
|
| 320 |
+
|
| 321 |
+
if (parentNode && !parentNode.data.is_dir) {
|
| 322 |
+
setMoveError("Cannot move items into a file.");
|
| 323 |
+
setIsMoving(false);
|
| 324 |
+
return;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
let successCount = 0;
|
| 328 |
+
let firstError = null;
|
| 329 |
+
|
| 330 |
+
const results = await Promise.allSettled(
|
| 331 |
+
dragIds.map(id => {
|
| 332 |
+
const node = treeRef.current?.get(id);
|
| 333 |
+
if (!node) return Promise.reject(new Error(`Node with ID ${id} not found.`));
|
| 334 |
+
|
| 335 |
+
const sourcePath = node.data.relativePath;
|
| 336 |
+
const itemName = node.data.name;
|
| 337 |
+
// Construct destination path: parentPath/itemName
|
| 338 |
+
// Ensure no leading/trailing slashes on parentPath (unless it's root '.')
|
| 339 |
+
// and no leading slash on itemName
|
| 340 |
+
const cleanParentPath = parentPath === '.' ? '' : parentPath.replace(/^\/+|\/+$/g, '');
|
| 341 |
+
const cleanItemName = itemName.replace(/^\/+/,'');
|
| 342 |
+
const destinationPath = cleanParentPath ? `${cleanParentPath}/${cleanItemName}` : cleanItemName;
|
| 343 |
+
|
| 344 |
+
console.log(`Attempting move: ${sourcePath} -> ${destinationPath}`);
|
| 345 |
+
return moveInputItem(sourcePath, destinationPath);
|
| 346 |
+
})
|
| 347 |
+
);
|
| 348 |
+
|
| 349 |
+
results.forEach((result, i) => {
|
| 350 |
+
const nodeId = dragIds[i];
|
| 351 |
+
const node = treeRef.current?.get(nodeId);
|
| 352 |
+
const itemName = node?.data?.name || `Item ${i+1}`;
|
| 353 |
+
if (result.status === 'fulfilled') {
|
| 354 |
+
console.log(`Successfully moved ${itemName}:`, result.value);
|
| 355 |
+
successCount++;
|
| 356 |
+
} else {
|
| 357 |
+
console.error(`Failed to move ${itemName}:`, result.reason);
|
| 358 |
+
if (!firstError) {
|
| 359 |
+
firstError = result.reason?.message || `Failed to move ${itemName}.`;
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
setIsMoving(false);
|
| 366 |
+
|
| 367 |
+
if (firstError) {
|
| 368 |
+
setMoveError(`Move Error: ${firstError}${successCount > 0 ? ` (${successCount} items moved successfully)` : ''}`);
|
| 369 |
+
} else {
|
| 370 |
+
console.log(`Successfully moved ${successCount} item(s).`);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
if (successCount > 0 || firstError) {
|
| 374 |
+
setNewlyCreatedItemId(null); // Clear highlight after move attempt
|
| 375 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) after successful/failed move attempt.`);
|
| 376 |
+
console.log(`LOG: Clearing lastUploadedNames after successful/failed move attempt.`);
|
| 377 |
+
setLastUploadedNames([]); // Also clear search list
|
| 378 |
+
refreshInputData(); // Refresh data after move
|
| 379 |
+
setSelectedNodes([]);
|
| 380 |
+
treeRef.current?.deselectAll();
|
| 381 |
+
} else {
|
| 382 |
+
console.log("No changes detected from move operation, not refreshing.");
|
| 383 |
+
}
|
| 384 |
+
};
|
| 385 |
+
// --- End Move Handler ---
|
| 386 |
+
|
| 387 |
+
const handleCreateFolder = async () => {
|
| 388 |
+
if (!newFolderName.trim() || isSubmittingModal) return;
|
| 389 |
+
let parentPath = '.';
|
| 390 |
+
let parentNode = null; // Keep track of the parent node
|
| 391 |
+
// Target folder creation based on selection
|
| 392 |
+
const selectedDir = selectedNodes.find(n => n.is_dir);
|
| 393 |
+
if (selectedNodes.length === 1 && selectedDir) {
|
| 394 |
+
parentPath = selectedDir.relativePath;
|
| 395 |
+
parentNode = treeRef.current?.get(selectedDir.id); // Get the node itself
|
| 396 |
+
} else if (selectedNodes.length > 0) {
|
| 397 |
+
// If multiple items or a file is selected, find the parent of the first selected item
|
| 398 |
+
const firstNodeId = selectedNodes[0].id;
|
| 399 |
+
const firstNode = treeRef.current?.get(firstNodeId);
|
| 400 |
+
const parent = firstNode?.parent;
|
| 401 |
+
if (parent) {
|
| 402 |
+
parentPath = parent.data.relativePath;
|
| 403 |
+
parentNode = parent; // Get the parent node
|
| 404 |
+
} else {
|
| 405 |
+
parentPath = '.'; // Default to root if no parent found
|
| 406 |
+
parentNode = null;
|
| 407 |
+
}
|
| 408 |
+
} // else defaults to '.' (root)
|
| 409 |
+
|
| 410 |
+
// --- START EDIT: Attempt to open the parent directory ---
|
| 411 |
+
if (parentNode && parentNode.id && !parentNode.isOpen && treeRef.current) {
|
| 412 |
+
try {
|
| 413 |
+
console.log(`LOG: handleCreateFolder attempting to open parent node: ${parentNode.id}`);
|
| 414 |
+
await treeRef.current.open(parentNode.id, { autoSelect: false });
|
| 415 |
+
console.log(`LOG: handleCreateFolder successfully opened parent node: ${parentNode.id}`);
|
| 416 |
+
} catch (openError) {
|
| 417 |
+
console.warn(`LOG: handleCreateFolder failed to open parent node ${parentNode.id}:`, openError);
|
| 418 |
+
// Continue anyway, maybe it doesn't exist yet or another issue
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
// --- END EDIT ---
|
| 422 |
+
|
| 423 |
+
const cleanParentPath = parentPath === '.' ? '' : parentPath.replace(/^\/+|\/+$/g, '');
|
| 424 |
+
const cleanNewFolderName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
|
| 425 |
+
const fullPath = cleanParentPath ? `${cleanParentPath}/${cleanNewFolderName}` : cleanNewFolderName;
|
| 426 |
+
|
| 427 |
+
setIsSubmittingModal(true);
|
| 428 |
+
setModalError(null);
|
| 429 |
+
try {
|
| 430 |
+
await createInputDirectory(fullPath);
|
| 431 |
+
setNewFolderName('');
|
| 432 |
+
setIsCreateFolderModalOpen(false);
|
| 433 |
+
// Set highlight target *before* refresh
|
| 434 |
+
setNewlyCreatedItemId(fullPath);
|
| 435 |
+
refreshInputData(); // Refresh data after creation
|
| 436 |
+
// Trigger background activity briefly
|
| 437 |
+
setBgIsActive(true);
|
| 438 |
+
setTimeout(() => setBgIsActive(false), 1000);
|
| 439 |
+
} catch (e) {
|
| 440 |
+
console.error("Failed to create directory:", e);
|
| 441 |
+
setModalError(e.message || 'Could not create folder.');
|
| 442 |
+
} finally {
|
| 443 |
+
setIsSubmittingModal(false);
|
| 444 |
+
}
|
| 445 |
+
setLastUploadedNames([]); // Also clear search list
|
| 446 |
+
};
|
| 447 |
+
|
| 448 |
+
const handleDownload = async () => {
|
| 449 |
+
if (selectedNodes.length === 0 || isDownloading || triggerBgDownload) return; // Prevent overlap
|
| 450 |
+
|
| 451 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleDownload start.`);
|
| 452 |
+
setNewlyCreatedItemId(null); // Clear highlight on download start
|
| 453 |
+
setIsDownloading(true);
|
| 454 |
+
setDownloadStatus({ message: `Starting download for ${selectedNodes.length} item(s)...`, error: false });
|
| 455 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleDownload start.`);
|
| 456 |
+
setLastUploadedNames([]); // Also clear search list
|
| 457 |
+
setTriggerBgDownload(true); // <-- Trigger background download animation
|
| 458 |
+
|
| 459 |
+
let successCount = 0;
|
| 460 |
+
let firstError = null;
|
| 461 |
+
|
| 462 |
+
for (const node of selectedNodes) {
|
| 463 |
+
try {
|
| 464 |
+
setDownloadStatus({ message: `Downloading ${node.name}...`, error: false });
|
| 465 |
+
const response = await downloadInputItem(node.relativePath);
|
| 466 |
+
const blob = await response.blob();
|
| 467 |
+
|
| 468 |
+
// Extract filename from content-disposition or use node name
|
| 469 |
+
let filename = node.name;
|
| 470 |
+
const disposition = response.headers.get('content-disposition');
|
| 471 |
+
if (disposition && disposition.indexOf('attachment') !== -1) {
|
| 472 |
+
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
| 473 |
+
const matches = filenameRegex.exec(disposition);
|
| 474 |
+
if (matches != null && matches[1]) {
|
| 475 |
+
filename = matches[1].replace(/['"]/g, '');
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
// If it's a directory download, API likely sends a zip
|
| 479 |
+
if (node.is_dir && !filename.toLowerCase().endsWith('.zip')) {
|
| 480 |
+
filename += '.zip';
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// Create temporary link and click
|
| 484 |
+
const url = window.URL.createObjectURL(blob);
|
| 485 |
+
const a = document.createElement('a');
|
| 486 |
+
a.style.display = 'none';
|
| 487 |
+
a.href = url;
|
| 488 |
+
a.download = filename;
|
| 489 |
+
document.body.appendChild(a);
|
| 490 |
+
a.click();
|
| 491 |
+
window.URL.revokeObjectURL(url);
|
| 492 |
+
a.remove();
|
| 493 |
+
successCount++;
|
| 494 |
+
// Small delay between downloads to prevent browser blocking
|
| 495 |
+
await new Promise(resolve => setTimeout(resolve, 200));
|
| 496 |
+
} catch (error) {
|
| 497 |
+
console.error(`Failed to download ${node.relativePath}:`, error);
|
| 498 |
+
if (!firstError) {
|
| 499 |
+
firstError = error.message || `Failed to download ${node.name}.`;
|
| 500 |
+
}
|
| 501 |
+
// Optionally break the loop on first error
|
| 502 |
+
// break;
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
setIsDownloading(false);
|
| 507 |
+
if (firstError) {
|
| 508 |
+
setDownloadStatus({ message: `Download failed: ${firstError}`, error: true });
|
| 509 |
+
} else {
|
| 510 |
+
setDownloadStatus({ message: `Successfully downloaded ${successCount} item(s).`, error: false });
|
| 511 |
+
}
|
| 512 |
+
// Clear status message after a delay
|
| 513 |
+
setTimeout(() => setDownloadStatus({ message: '', error: false }), 5000);
|
| 514 |
+
};
|
| 515 |
+
|
| 516 |
+
const handleDelete = async () => {
|
| 517 |
+
if (selectedNodes.length === 0 || isSubmittingModal || triggerBgDelete) return; // Prevent overlap
|
| 518 |
+
|
| 519 |
+
const itemsToDelete = [...selectedNodes];
|
| 520 |
+
setIsSubmittingModal(true);
|
| 521 |
+
setModalError(null);
|
| 522 |
+
setTriggerBgDelete(true); // <-- Trigger background delete animation
|
| 523 |
+
setNewlyCreatedItemId(null); // Clear highlight on delete start
|
| 524 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleDelete start.`);
|
| 525 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleDelete start.`);
|
| 526 |
+
setLastUploadedNames([]); // Also clear search list
|
| 527 |
+
|
| 528 |
+
let successCount = 0;
|
| 529 |
+
let firstError = null;
|
| 530 |
+
|
| 531 |
+
const results = await Promise.allSettled(
|
| 532 |
+
itemsToDelete.map(node => deleteInputItem(node.relativePath))
|
| 533 |
+
);
|
| 534 |
+
|
| 535 |
+
results.forEach((result, index) => {
|
| 536 |
+
if (result.status === 'fulfilled') {
|
| 537 |
+
successCount++;
|
| 538 |
+
} else {
|
| 539 |
+
console.error(`Failed to delete ${itemsToDelete[index].relativePath}:`, result.reason);
|
| 540 |
+
if (!firstError) {
|
| 541 |
+
firstError = result.reason?.message || `Failed to delete ${itemsToDelete[index].name}.`;
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
+
setIsSubmittingModal(false);
|
| 547 |
+
|
| 548 |
+
if (firstError) {
|
| 549 |
+
setModalError(`Error: ${firstError}${successCount > 0 ? ` (${successCount} items deleted successfully)` : ''}`);
|
| 550 |
+
} else {
|
| 551 |
+
console.log(`Successfully deleted ${successCount} items.`);
|
| 552 |
+
setIsDeleteModalOpen(false);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
if (successCount > 0) {
|
| 556 |
+
// await fetchInitialData();
|
| 557 |
+
refreshInputData(); // Refresh data after deletion
|
| 558 |
+
setSelectedNodes([]);
|
| 559 |
+
setBgIsActive(false); // Deactivate background animation after deletion
|
| 560 |
+
}
|
| 561 |
+
};
|
| 562 |
+
|
| 563 |
+
const openCreateFolderModal = () => {
|
| 564 |
+
setNewFolderName('');
|
| 565 |
+
setModalError(null);
|
| 566 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to openCreateFolderModal.`);
|
| 567 |
+
setIsCreateFolderModalOpen(true);
|
| 568 |
+
setNewlyCreatedItemId(null); // Clear highlight when opening modal
|
| 569 |
+
console.log(`LOG: Clearing lastUploadedNames due to openCreateFolderModal.`);
|
| 570 |
+
setLastUploadedNames([]); // Also clear search list
|
| 571 |
+
// Autofocus the input field
|
| 572 |
+
setTimeout(() => createFolderInputRef.current?.focus(), 100);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
const openDeleteModal = () => {
|
| 576 |
+
setModalError(null);
|
| 577 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to openDeleteModal.`);
|
| 578 |
+
setIsDeleteModalOpen(true);
|
| 579 |
+
setNewlyCreatedItemId(null); // Clear highlight when opening modal
|
| 580 |
+
console.log(`LOG: Clearing lastUploadedNames due to openDeleteModal.`);
|
| 581 |
+
setLastUploadedNames([]); // Also clear search list
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
const onDrop = useCallback(
|
| 585 |
+
async acceptedFiles => {
|
| 586 |
+
console.log("LOG: onDrop START. Files dropped:", acceptedFiles.length);
|
| 587 |
+
if (!acceptedFiles.length) return;
|
| 588 |
+
|
| 589 |
+
// Clear previous highlight search list
|
| 590 |
+
console.log("LOG: onDrop clearing lastUploadedNames at start.")
|
| 591 |
+
setLastUploadedNames([]);
|
| 592 |
+
|
| 593 |
+
// --- Record potential names for highlighting --- Start
|
| 594 |
+
const namesToHighlight = acceptedFiles.map(file => {
|
| 595 |
+
// If it's a directory upload (folder dragged in), webkitRelativePath exists
|
| 596 |
+
if (file.webkitRelativePath) {
|
| 597 |
+
// Extract the top-level folder name
|
| 598 |
+
return file.webkitRelativePath.split('/')[0];
|
| 599 |
+
} else {
|
| 600 |
+
// Regular file upload
|
| 601 |
+
return file.name;
|
| 602 |
+
}
|
| 603 |
+
});
|
| 604 |
+
// Remove duplicates (important if a folder contains multiple files)
|
| 605 |
+
const uniqueNames = [...new Set(namesToHighlight)];
|
| 606 |
+
console.log("LOG: onDrop names recorded for potential highlight:", uniqueNames);
|
| 607 |
+
setLastUploadedNames(uniqueNames);
|
| 608 |
+
// --- Record potential names for highlighting --- End
|
| 609 |
+
|
| 610 |
+
// Determine upload target based on selection
|
| 611 |
+
let target = '.'; // Default to root
|
| 612 |
+
let targetNode = null; // Track target node
|
| 613 |
+
const selectedDir = selectedNodes.find(n => n.is_dir);
|
| 614 |
+
if (selectedNodes.length === 1 && selectedDir) {
|
| 615 |
+
target = selectedDir.relativePath;
|
| 616 |
+
targetNode = treeRef.current?.get(selectedDir.id); // Get selected dir node
|
| 617 |
+
} else if (selectedNodes.length > 0) {
|
| 618 |
+
// If multiple items or a file is selected, upload to parent of first selected
|
| 619 |
+
const firstNodeId = selectedNodes[0].id;
|
| 620 |
+
const firstNode = treeRef.current?.get(firstNodeId);
|
| 621 |
+
const parent = firstNode?.parent;
|
| 622 |
+
if (parent) {
|
| 623 |
+
target = parent.data.relativePath;
|
| 624 |
+
targetNode = parent; // Target is the parent node
|
| 625 |
+
} else {
|
| 626 |
+
target = '.'; // Default to root if no parent
|
| 627 |
+
targetNode = null;
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// --- START EDIT: Attempt to open the target directory ---
|
| 632 |
+
if (targetNode && targetNode.id && !targetNode.isOpen && treeRef.current) {
|
| 633 |
+
try {
|
| 634 |
+
console.log(`LOG: onDrop attempting to open target node: ${targetNode.id}`);
|
| 635 |
+
await treeRef.current.open(targetNode.id, { autoSelect: false });
|
| 636 |
+
console.log(`LOG: onDrop successfully opened target node: ${targetNode.id}`);
|
| 637 |
+
} catch (openError) {
|
| 638 |
+
console.warn(`LOG: onDrop failed to open target node ${targetNode.id}:`, openError);
|
| 639 |
+
// Continue upload anyway
|
| 640 |
+
}
|
| 641 |
+
}
|
| 642 |
+
// --- END EDIT ---
|
| 643 |
+
|
| 644 |
+
console.log(`LOG: onDrop determined targetPath: ${target}`);
|
| 645 |
+
console.log(`Uploading to target path: ${target}`);
|
| 646 |
+
|
| 647 |
+
console.log("LOG: onDrop setting isUploading = true");
|
| 648 |
+
setIsUploading(true);
|
| 649 |
+
setUploadError(null);
|
| 650 |
+
setLastUploadResult(null); // Clear previous success message
|
| 651 |
+
console.log(`LOG: onDrop explicitly clearing newlyCreatedItemId (${newlyCreatedItemId}) at start of try block.`);
|
| 652 |
+
setNewlyCreatedItemId(null); // Explicitly clear any previous highlight
|
| 653 |
+
try {
|
| 654 |
+
console.log(`LOG: onDrop BEFORE handleUpload call. Target: ${target}`);
|
| 655 |
+
await handleUpload(acceptedFiles, target);
|
| 656 |
+
console.log("LOG: onDrop AFTER handleUpload call completed.");
|
| 657 |
+
// Highlight is now handled by the new useEffect hook based on lastUploadedNames
|
| 658 |
+
console.log("LOG: onDrop setting lastUploadResult = true");
|
| 659 |
+
setLastUploadResult(true); // Indicate success
|
| 660 |
+
// Set names *and* trigger the search effect AFTER upload/refresh initiated
|
| 661 |
+
console.log("LOG: onDrop setting lastUploadedNames and incrementing uploadCycle");
|
| 662 |
+
setLastUploadedNames(uniqueNames); // Set names just before incrementing cycle
|
| 663 |
+
setUploadCycle(c => c + 1); // Trigger the search effect
|
| 664 |
+
// Clear success message after a delay
|
| 665 |
+
setTimeout(() => {
|
| 666 |
+
setLastUploadResult(null);
|
| 667 |
+
// Maybe clear highlight here too, after a longer delay?
|
| 668 |
+
// setNewlyCreatedItemId(null);
|
| 669 |
+
}, 5000);
|
| 670 |
+
} catch (e) {
|
| 671 |
+
console.error('LOG: onDrop CAUGHT ERROR:', e);
|
| 672 |
+
console.error('Upload failed:', e);
|
| 673 |
+
setUploadError(e.message ?? 'An error occurred during upload.');
|
| 674 |
+
// Clear error message after a delay
|
| 675 |
+
setTimeout(() => setUploadError(null), 8000);
|
| 676 |
+
} finally {
|
| 677 |
+
console.log("LOG: onDrop setting isUploading = false (in finally block)");
|
| 678 |
+
setIsUploading(false);
|
| 679 |
+
}
|
| 680 |
+
},
|
| 681 |
+
[selectedNodes, handleUpload, treeRef] // Removed refreshInputData dependency here as it's called within handleUpload
|
| 682 |
+
);
|
| 683 |
+
|
| 684 |
+
const {
|
| 685 |
+
getRootProps,
|
| 686 |
+
getInputProps,
|
| 687 |
+
isDragActive,
|
| 688 |
+
open: openFileDialog,
|
| 689 |
+
} = useDropzone({
|
| 690 |
+
onDrop,
|
| 691 |
+
noClick: true, // We have dedicated upload buttons
|
| 692 |
+
noKeyboard: true,
|
| 693 |
+
// Allow dropping directories (though handled via zipping in handleUpload)
|
| 694 |
+
// Note: This doesn't directly enable folder uploads in all browsers without user selecting folder manually
|
| 695 |
+
// accept: { 'application/zip': ['.zip'] }, // Primarily suggest zipping, but handleUpload adapts
|
| 696 |
+
});
|
| 697 |
+
|
| 698 |
+
const actionButtonClass = "flex items-center gap-2 px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm transition-colors duration-200 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed";
|
| 699 |
+
|
| 700 |
+
// Pass click tracking props to Node
|
| 701 |
+
const handleNodeClick = (id, time) => {
|
| 702 |
+
setLastClick({ id, time });
|
| 703 |
+
// Set background active if a node is clicked (even if it becomes deselected on double click)
|
| 704 |
+
setBgIsActive(true);
|
| 705 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleNodeClick.`);
|
| 706 |
+
setNewlyCreatedItemId(null); // Clear highlight on click
|
| 707 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleNodeClick.`);
|
| 708 |
+
setLastUploadedNames([]); // Also clear search list
|
| 709 |
+
// Maybe reset after a short delay if only single click?
|
| 710 |
+
// setTimeout(() => setBgIsActive(selectedNodes.length > 0), 400); // Check selection after double-click threshold
|
| 711 |
+
};
|
| 712 |
+
|
| 713 |
+
// Define the background click handler similar to ConfigSelector
|
| 714 |
+
const handleBackgroundClick = useCallback((e) => {
|
| 715 |
+
// Don't clear if a modal is open
|
| 716 |
+
if (isDeleteModalOpen || isCreateFolderModalOpen) return;
|
| 717 |
+
|
| 718 |
+
const clickedOnRow = e.target.closest('[role="treeitem"]');
|
| 719 |
+
const clickedOnScrollbar = e.target.closest('.simplebar-scrollbar'); // Adjust if needed
|
| 720 |
+
const clickedOnActionButton = e.target.closest('[data-action-button="true"]');
|
| 721 |
+
const clickedOnModal = e.target.closest('[role="dialog"]'); // Check if click is inside any modal
|
| 722 |
+
|
| 723 |
+
if (!clickedOnRow && !clickedOnScrollbar && !clickedOnActionButton && !clickedOnModal) {
|
| 724 |
+
if (treeRef.current?.deselectAll) {
|
| 725 |
+
treeRef.current.deselectAll();
|
| 726 |
+
}
|
| 727 |
+
if (typeof setSelectedNodes === 'function') {
|
| 728 |
+
setSelectedNodes([]);
|
| 729 |
+
setBgIsActive(false); // <-- Deactivate background on deselect all
|
| 730 |
+
setNewlyCreatedItemId(null); // Clear highlight on background click
|
| 731 |
+
console.log(`LOG: Clearing newlyCreatedItemId (${newlyCreatedItemId}) due to handleBackgroundClick.`);
|
| 732 |
+
console.log(`LOG: Clearing lastUploadedNames due to handleBackgroundClick.`);
|
| 733 |
+
setLastUploadedNames([]); // Also clear search list
|
| 734 |
+
}
|
| 735 |
+
}
|
| 736 |
+
}, [isDeleteModalOpen, isCreateFolderModalOpen]);
|
| 737 |
+
|
| 738 |
+
return (
|
| 739 |
+
// Outer div for dropzone and background click handling, no padding here
|
| 740 |
+
<div {...getRootProps({ className: 'flex flex-col h-full relative' })} onMouseDown={handleBackgroundClick}>
|
| 741 |
+
<input
|
| 742 |
+
{...getInputProps({
|
| 743 |
+
multiple: true,
|
| 744 |
+
// Enable directory uploads (may require zipping)
|
| 745 |
+
webkitdirectory: "true",
|
| 746 |
+
mozdirectory: "true",
|
| 747 |
+
directory: "true",
|
| 748 |
+
})}
|
| 749 |
+
/>
|
| 750 |
+
|
| 751 |
+
{/* --- Add PourOverBackground --- */}
|
| 752 |
+
<PourOverBackground
|
| 753 |
+
isActive={bgIsActive}
|
| 754 |
+
triggerDownload={triggerBgDownload}
|
| 755 |
+
triggerDelete={triggerBgDelete}
|
| 756 |
+
onDownloadComplete={handleDownloadAnimComplete}
|
| 757 |
+
onDeleteComplete={handleDeleteAnimComplete}
|
| 758 |
+
/>
|
| 759 |
+
|
| 760 |
+
{/* Main Content Area (Wrapped and Centered) */}
|
| 761 |
+
<div className="relative z-10 flex-grow flex flex-col max-w-7xl w-full mx-auto p-6">
|
| 762 |
+
|
| 763 |
+
{/* Header - Apply background, blur, padding, border */}
|
| 764 |
+
<div className="flex justify-between items-center mb-4 bg-gray-900 bg-opacity-80 backdrop-blur-sm p-4 rounded-t-lg border-b border-gray-700">
|
| 765 |
+
<h1 className="text-2xl font-bold text-gray-100">Input Manager</h1>
|
| 766 |
+
<div className="flex items-center gap-2">
|
| 767 |
+
<EnhancedButton
|
| 768 |
+
onClick={openCreateFolderModal}
|
| 769 |
+
className={`${actionButtonClass} hover:bg-green-600 ${selectedNodes.length > 0 ? 'opacity-75' : ''}`}
|
| 770 |
+
title={selectedNodes.length === 1 && selectedNodes[0].is_dir ? `Create folder inside '${selectedNodes[0].name}'` : (selectedNodes.length > 0 ? `Create folder alongside selected item(s)` : 'Create folder in root')}
|
| 771 |
+
data-action-button="true"
|
| 772 |
+
particleColor="#22c55e"
|
| 773 |
+
>
|
| 774 |
+
<FiFolderPlus /> Create Folder
|
| 775 |
+
</EnhancedButton>
|
| 776 |
+
<EnhancedButton
|
| 777 |
+
onClick={openFileDialog}
|
| 778 |
+
className={`${actionButtonClass} hover:bg-indigo-600 ${selectedNodes.length > 0 ? 'opacity-75' : ''}`}
|
| 779 |
+
title={selectedNodes.length === 1 && selectedNodes[0].is_dir ? `Upload to '${selectedNodes[0].name}' (Folders will be zipped)` : (selectedNodes.length > 0 ? `Upload alongside selected item(s) (Folders will be zipped)`: `Upload to root (Folders will be zipped)`)}
|
| 780 |
+
data-action-button="true"
|
| 781 |
+
particleColor="#6366f1"
|
| 782 |
+
>
|
| 783 |
+
<FiUploadCloud /> Upload
|
| 784 |
+
</EnhancedButton>
|
| 785 |
+
<EnhancedButton
|
| 786 |
+
onClick={handleDownload}
|
| 787 |
+
className={`${actionButtonClass}
|
| 788 |
+
${selectedNodes.length > 0 && !isDownloading ? 'animate-pulse-green-download' : 'hover:bg-green-700'}
|
| 789 |
+
${isShaking ? 'animate-shake' : ''}`}
|
| 790 |
+
title="Download Selected Item(s)"
|
| 791 |
+
disabled={selectedNodes.length === 0 || isDownloading}
|
| 792 |
+
data-action-button="true"
|
| 793 |
+
particleColor="#34d399"
|
| 794 |
+
>
|
| 795 |
+
{isDownloading ? <FiLoader className="animate-spin"/> : <FiDownloadCloud />}
|
| 796 |
+
{isDownloading ? 'Downloading...' : 'Download'}
|
| 797 |
+
</EnhancedButton>
|
| 798 |
+
<EnhancedButton
|
| 799 |
+
onClick={openDeleteModal}
|
| 800 |
+
className={`${actionButtonClass}
|
| 801 |
+
${selectedNodes.length > 0 ? 'animate-pulse-red' : 'text-red-400 hover:bg-red-900 hover:text-red-300'}
|
| 802 |
+
${isShaking ? 'animate-shake' : ''}`}
|
| 803 |
+
title="Delete Selected Item(s)"
|
| 804 |
+
disabled={selectedNodes.length === 0}
|
| 805 |
+
data-action-button="true"
|
| 806 |
+
particleColor="#ef4444"
|
| 807 |
+
>
|
| 808 |
+
<FiTrash2 /> Delete
|
| 809 |
+
</EnhancedButton>
|
| 810 |
+
</div>
|
| 811 |
+
</div>
|
| 812 |
+
|
| 813 |
+
{/* Loading/Error States for Initial Load - Apply background */}
|
| 814 |
+
{isLoading && !treeData && (
|
| 815 |
+
<div className="flex-grow flex items-center justify-center text-gray-400 bg-gray-900 bg-opacity-80 backdrop-blur-sm p-4 rounded-b-lg">
|
| 816 |
+
<FiLoader className="animate-spin mr-3" /> Loading input files...
|
| 817 |
+
</div>
|
| 818 |
+
)}
|
| 819 |
+
{error && !treeData && (
|
| 820 |
+
<div className="flex-grow flex flex-col items-center justify-center text-red-400 bg-red-900 bg-opacity-60 backdrop-blur-sm p-6 rounded-b-lg border border-red-700">
|
| 821 |
+
<FiAlertTriangle className="w-10 h-10 mb-2" />
|
| 822 |
+
<p>Error loading input structure:</p>
|
| 823 |
+
<p className="text-sm mt-1">{error}</p>
|
| 824 |
+
<button onClick={() => fetchInputData(true)} className="mt-4 px-3 py-1.5 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 text-sm">
|
| 825 |
+
Retry
|
| 826 |
+
</button>
|
| 827 |
+
</div>
|
| 828 |
+
)}
|
| 829 |
+
|
| 830 |
+
{/* Tree View Area - Apply background */}
|
| 831 |
+
{treeData && (
|
| 832 |
+
<div className={`flex-grow border-b border-r border-l border-gray-700 rounded-b-lg bg-gray-900 bg-opacity-80 backdrop-blur-sm overflow-auto relative min-h-0 ${isDragActive ? 'border-blue-500 ring-2 ring-blue-500 ring-opacity-50' : ''} ${isLoading ? 'pointer-events-none' : ''}`}>
|
| 833 |
+
{/* Dropzone overlay */}
|
| 834 |
+
{isDragActive && (
|
| 835 |
+
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-70 z-10 pointer-events-none">
|
| 836 |
+
<p className="text-lg font-semibold text-blue-300">Drop files/folders here to upload</p>
|
| 837 |
+
</div>
|
| 838 |
+
)}
|
| 839 |
+
{/* Loading overlay during refresh */}
|
| 840 |
+
{isLoading && (
|
| 841 |
+
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-50 z-10">
|
| 842 |
+
<FiLoader className="animate-spin text-gray-400 text-2xl" />
|
| 843 |
+
</div>
|
| 844 |
+
)}
|
| 845 |
+
{/* Tree or Empty State */}
|
| 846 |
+
{(treeData && treeData.length > 0) ? (
|
| 847 |
+
<Tree
|
| 848 |
+
ref={treeRef}
|
| 849 |
+
data={treeData}
|
| 850 |
+
openByDefault={false}
|
| 851 |
+
width="100%"
|
| 852 |
+
height={1000} // Use large height, container handles scrolling
|
| 853 |
+
indent={24}
|
| 854 |
+
rowHeight={32}
|
| 855 |
+
paddingTop={10}
|
| 856 |
+
paddingBottom={10}
|
| 857 |
+
onSelect={handleSelect}
|
| 858 |
+
onToggle={handleToggle}
|
| 859 |
+
onMove={handleMove}
|
| 860 |
+
disableDrop={({ parentNode, dropTarget }) => {
|
| 861 |
+
// Prevent dropping *onto* a file
|
| 862 |
+
return dropTarget && !dropTarget.data.is_dir;
|
| 863 |
+
}}
|
| 864 |
+
children={(props) => <Node {...props} onNodeClick={handleNodeClick} lastClick={lastClick} newlyCreatedItemId={newlyCreatedItemId} />}
|
| 865 |
+
>
|
| 866 |
+
</Tree>
|
| 867 |
+
) : (
|
| 868 |
+
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
| 869 |
+
<p className="mb-4">Input directory is empty.</p>
|
| 870 |
+
<button onClick={openFileDialog} className={`${actionButtonClass} bg-blue-600 hover:bg-blue-500 text-white`}>
|
| 871 |
+
<FiUploadCloud /> Upload Files or Folders
|
| 872 |
+
</button>
|
| 873 |
+
</div>
|
| 874 |
+
)}
|
| 875 |
+
</div>
|
| 876 |
+
)}
|
| 877 |
+
|
| 878 |
+
{/* Status Bar Area - Apply background */}
|
| 879 |
+
<div className="h-8 mt-2 text-sm flex items-center gap-4 overflow-hidden whitespace-nowrap px-4 py-1 bg-gray-900 bg-opacity-80 backdrop-blur-sm rounded-lg border border-gray-700">
|
| 880 |
+
{/* Upload Status */}
|
| 881 |
+
{isUploading && (
|
| 882 |
+
<div className="flex items-center text-blue-400 flex-shrink-0">
|
| 883 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> Uploading...
|
| 884 |
+
</div>
|
| 885 |
+
)}
|
| 886 |
+
{uploadError && <p className="text-red-400 truncate" title={uploadError}>Upload Error: {uploadError}</p>}
|
| 887 |
+
{lastUploadResult && <p className="text-green-400 flex items-center"><FiCheckCircle className="mr-1 flex-shrink-0"/> Upload successful!</p>}
|
| 888 |
+
|
| 889 |
+
{/* Download Status */}
|
| 890 |
+
{isDownloading && (
|
| 891 |
+
<div className="flex items-center text-blue-400 flex-shrink-0">
|
| 892 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> {downloadStatus.message}
|
| 893 |
+
</div>
|
| 894 |
+
)}
|
| 895 |
+
{!isDownloading && downloadStatus.message && (
|
| 896 |
+
<p className={`${downloadStatus.error ? 'text-red-400' : 'text-green-400'} truncate`} title={downloadStatus.message}>{downloadStatus.message}</p>
|
| 897 |
+
)}
|
| 898 |
+
|
| 899 |
+
{/* Move Status */}
|
| 900 |
+
{isMoving && (
|
| 901 |
+
<div className="flex items-center text-blue-400 flex-shrink-0">
|
| 902 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> Moving items...
|
| 903 |
+
</div>
|
| 904 |
+
)}
|
| 905 |
+
{moveError && <p className="text-red-400 truncate" title={moveError}>Move Error: {moveError}</p>}
|
| 906 |
+
</div>
|
| 907 |
+
|
| 908 |
+
</div> { /* End Main Content Wrapper */ }
|
| 909 |
+
|
| 910 |
+
{/* --- Modals --- */}
|
| 911 |
+
{/* Modals remain outside the centered content */}
|
| 912 |
+
<Modal
|
| 913 |
+
isOpen={isDeleteModalOpen}
|
| 914 |
+
onClose={() => !isSubmittingModal && setIsDeleteModalOpen(false)}
|
| 915 |
+
title="Confirm Deletion"
|
| 916 |
+
>
|
| 917 |
+
<p className="mb-4">Are you sure you want to delete the following {selectedNodes.length} item(s)? This action cannot be undone.</p>
|
| 918 |
+
<ul className="list-disc list-inside mb-4 max-h-40 overflow-y-auto bg-gray-700 p-2 rounded border border-gray-600">
|
| 919 |
+
{selectedNodes.map(node => <li key={node.id} className="truncate text-sm py-0.5" title={node.relativePath}>{node.relativePath}</li>)}
|
| 920 |
+
</ul>
|
| 921 |
+
{/* Modal-specific error display */}
|
| 922 |
+
{modalError && (
|
| 923 |
+
<p className="text-red-400 text-sm mb-3 bg-red-900 bg-opacity-40 p-2 rounded border border-red-700">{modalError}</p>
|
| 924 |
+
)}
|
| 925 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 926 |
+
<button
|
| 927 |
+
onClick={() => setIsDeleteModalOpen(false)}
|
| 928 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200"
|
| 929 |
+
disabled={isSubmittingModal}
|
| 930 |
+
>
|
| 931 |
+
Cancel
|
| 932 |
+
</button>
|
| 933 |
+
<EnhancedButton
|
| 934 |
+
onClick={handleDelete}
|
| 935 |
+
className={`px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white flex items-center disabled:opacity-50
|
| 936 |
+
${isDeleteModalOpen && !isSubmittingModal && selectedNodes.length > 0 ? 'animate-pulse-red' : ''}`}
|
| 937 |
+
disabled={isSubmittingModal || selectedNodes.length === 0}
|
| 938 |
+
particleColor="#ef4444"
|
| 939 |
+
>
|
| 940 |
+
{isSubmittingModal ? <FiLoader className="animate-spin mr-2" /> : <FiTrash2 className="mr-1" />}
|
| 941 |
+
{isSubmittingModal ? 'Deleting...' : 'Delete'}
|
| 942 |
+
</EnhancedButton>
|
| 943 |
+
</div>
|
| 944 |
+
</Modal>
|
| 945 |
+
|
| 946 |
+
{/* Create Folder Modal */}
|
| 947 |
+
<Modal
|
| 948 |
+
isOpen={isCreateFolderModalOpen}
|
| 949 |
+
onClose={() => !isSubmittingModal && setIsCreateFolderModalOpen(false)}
|
| 950 |
+
title="Create New Folder"
|
| 951 |
+
>
|
| 952 |
+
<label htmlFor="newFolderName" className="block mb-2 text-sm font-medium text-gray-300">Folder Name:</label>
|
| 953 |
+
<input
|
| 954 |
+
ref={createFolderInputRef}
|
| 955 |
+
id="newFolderName"
|
| 956 |
+
type="text"
|
| 957 |
+
value={newFolderName}
|
| 958 |
+
onChange={(e) => setNewFolderName(e.target.value)}
|
| 959 |
+
placeholder="e.g., my_data or path/to/my_data"
|
| 960 |
+
className={`w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 mb-1
|
| 961 |
+
${!newFolderName.trim() && isCreateFolderModalOpen ? 'animate-pulse-bg-subtle' : ''}`}
|
| 962 |
+
disabled={isSubmittingModal}
|
| 963 |
+
onKeyDown={(e) => e.key === 'Enter' && !isSubmittingModal && newFolderName.trim() && handleCreateFolder()}
|
| 964 |
+
/>
|
| 965 |
+
{/* Modal-specific error display */}
|
| 966 |
+
{modalError && <p className="text-red-400 text-xs mt-1 mb-3">Error: {modalError}</p>}
|
| 967 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 968 |
+
<button
|
| 969 |
+
onClick={() => setIsCreateFolderModalOpen(false)}
|
| 970 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200"
|
| 971 |
+
disabled={isSubmittingModal}
|
| 972 |
+
>
|
| 973 |
+
Cancel
|
| 974 |
+
</button>
|
| 975 |
+
<EnhancedButton
|
| 976 |
+
onClick={handleCreateFolder}
|
| 977 |
+
className={`px-4 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white flex items-center disabled:opacity-50
|
| 978 |
+
${newFolderName.trim() && !isSubmittingModal ? 'animate-pulse-blue-gold' : ''}`}
|
| 979 |
+
disabled={!newFolderName.trim() || isSubmittingModal}
|
| 980 |
+
particleColor="#60a5fa"
|
| 981 |
+
>
|
| 982 |
+
{isSubmittingModal ? <FiLoader className="animate-spin mr-2" /> : <FiFolderPlus className="mr-1"/>}
|
| 983 |
+
{isSubmittingModal ? 'Creating...' : 'Create'}
|
| 984 |
+
</EnhancedButton>
|
| 985 |
+
</div>
|
| 986 |
+
</Modal>
|
| 987 |
+
|
| 988 |
+
</div>
|
| 989 |
+
);
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
export default InputManager;
|
atk-interface/src/pages/LogManager.jsx
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom'; // Import useNavigate
|
| 3 |
+
import { Tree } from 'react-arborist'; // Import Tree
|
| 4 |
+
import { FiTrash2, FiRefreshCw, FiList, FiLoader, FiAlertTriangle, FiCheckCircle, FiSlash, FiFileText, FiChevronRight } from 'react-icons/fi'; // Add FiFileText, FiChevronRight
|
| 5 |
+
import { fetchLogFiles, deleteLogFile, clearAllLogs } from '../api';
|
| 6 |
+
import Modal from '../components/Modal';
|
| 7 |
+
import DocumentStackBackground from '../components/DocumentStackBackground'; // Import the new background
|
| 8 |
+
import EnhancedButton from '../components/EnhancedButton';
|
| 9 |
+
|
| 10 |
+
// --- Type Definition for react-arborist ---
|
| 11 |
+
/**
|
| 12 |
+
* @typedef {object} LogNodeData
|
| 13 |
+
* @property {string} id - Unique ID (the filename)
|
| 14 |
+
* @property {string} name - Display name (the filename)
|
| 15 |
+
* @property {string} taskId - Task ID extracted from filename
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
// --- Node Renderer Component ---
|
| 19 |
+
/** @param {import("react-arborist").NodeRendererProps<LogNodeData> & { navigate: Function, onNodeClick: Function, lastClick: { id: string | null, time: number } }} props */
|
| 20 |
+
function Node({ node, style, dragHandle, navigate, onNodeClick, lastClick }) {
|
| 21 |
+
const Icon = FiFileText; // Always a file for logs
|
| 22 |
+
const iconColor = 'text-gray-400'; // Example color for logs
|
| 23 |
+
|
| 24 |
+
const handleRowClick = (e) => {
|
| 25 |
+
const now = Date.now();
|
| 26 |
+
const DOUBLE_CLICK_THRESHOLD = 300; // ms
|
| 27 |
+
|
| 28 |
+
if (lastClick.id === node.id && (now - lastClick.time) < DOUBLE_CLICK_THRESHOLD) {
|
| 29 |
+
// Double click detected - navigate to log viewer
|
| 30 |
+
if (node.data.taskId) {
|
| 31 |
+
navigate(`/logs/${encodeURIComponent(node.data.taskId)}`); // Pass task ID
|
| 32 |
+
} else {
|
| 33 |
+
console.warn("Cannot navigate: Task ID missing for node", node.data.name);
|
| 34 |
+
}
|
| 35 |
+
// Reset last click
|
| 36 |
+
onNodeClick(null, 0);
|
| 37 |
+
} else {
|
| 38 |
+
// Single click
|
| 39 |
+
node.handleClick(e); // Let react-arborist handle selection
|
| 40 |
+
onNodeClick(node.id, now); // Update last click state
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
// Logs are always leaves, so no caret logic needed here
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div
|
| 48 |
+
ref={dragHandle} // Enable drag selection if needed, but disable drop
|
| 49 |
+
style={style}
|
| 50 |
+
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer
|
| 51 |
+
${node.isSelected ? 'bg-blue-700' : 'hover:bg-gray-700'}
|
| 52 |
+
${node.isEditing ? 'bg-gray-600' : ''}`} // Should not be editable
|
| 53 |
+
onClick={handleRowClick} // Use single click handler for selection/navigation
|
| 54 |
+
title={`Double-click to view ${node.data.name}`} // Tooltip
|
| 55 |
+
>
|
| 56 |
+
{/* Placeholder for caret space, keeps alignment */}
|
| 57 |
+
<span className="w-6 text-center flex items-center justify-center">
|
| 58 |
+
{/* No caret for files */}
|
| 59 |
+
</span>
|
| 60 |
+
|
| 61 |
+
{/* Icon + Name */}
|
| 62 |
+
<Icon className={`${iconColor} flex-shrink-0 mr-1`} />
|
| 63 |
+
<span className="truncate flex-grow text-gray-200 font-mono text-sm">
|
| 64 |
+
{node.data.name}
|
| 65 |
+
</span>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
function LogManager() {
|
| 72 |
+
const [treeData, setTreeData] = useState([]); // Use treeData state
|
| 73 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 74 |
+
const [error, setError] = useState(null);
|
| 75 |
+
/** @type {[LogNodeData[], Function]} */
|
| 76 |
+
const [selectedNodes, setSelectedNodes] = useState([]); // Store selected node data
|
| 77 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
| 78 |
+
const [isClearing, setIsClearing] = useState(false);
|
| 79 |
+
const [actionStatus, setActionStatus] = useState({ message: '', error: false, success: false });
|
| 80 |
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
| 81 |
+
const [isClearAllModalOpen, setIsClearAllModalOpen] = useState(false);
|
| 82 |
+
const [modalError, setModalError] = useState(null);
|
| 83 |
+
const [clearAllResult, setClearAllResult] = useState(null);
|
| 84 |
+
const treeRef = useRef(null); // Ref for tree instance
|
| 85 |
+
const navigate = useNavigate(); // Hook for navigation
|
| 86 |
+
const [lastClick, setLastClick] = useState({ id: null, time: 0 }); // For double-click tracking
|
| 87 |
+
|
| 88 |
+
// State for background animation triggers
|
| 89 |
+
const [triggerTossAnim, setTriggerTossAnim] = useState(false);
|
| 90 |
+
const [triggerExplodeAnim, setTriggerExplodeAnim] = useState(false);
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
// --- Helper Functions ---
|
| 94 |
+
const extractTaskId = (filename) => {
|
| 95 |
+
if (typeof filename === 'string' && filename.endsWith('.log')) {
|
| 96 |
+
return filename.slice(0, -4);
|
| 97 |
+
}
|
| 98 |
+
console.warn(`Could not extract task ID from filename: ${filename}`);
|
| 99 |
+
return null;
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
// --- Data Fetching & Adaptation ---
|
| 103 |
+
const fetchLogs = useCallback(async (showLoading = true) => {
|
| 104 |
+
if (showLoading) setIsLoading(true);
|
| 105 |
+
setError(null);
|
| 106 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 107 |
+
setModalError(null);
|
| 108 |
+
setClearAllResult(null);
|
| 109 |
+
try {
|
| 110 |
+
let files = await fetchLogFiles();
|
| 111 |
+
files.sort((a, b) => b.localeCompare(a)); // Sort descending
|
| 112 |
+
|
| 113 |
+
// Adapt data for react-arborist
|
| 114 |
+
const adaptedData = files.map(filename => {
|
| 115 |
+
const taskId = extractTaskId(filename);
|
| 116 |
+
return {
|
| 117 |
+
id: filename, // Use filename as unique ID
|
| 118 |
+
name: filename,
|
| 119 |
+
taskId: taskId,
|
| 120 |
+
};
|
| 121 |
+
});
|
| 122 |
+
setTreeData(adaptedData);
|
| 123 |
+
|
| 124 |
+
} catch (e) {
|
| 125 |
+
console.error("Failed to fetch log files:", e);
|
| 126 |
+
setError(e.message || 'Failed to load log files. Is the API server running?');
|
| 127 |
+
setTreeData([]); // Set empty on error
|
| 128 |
+
} finally {
|
| 129 |
+
if (showLoading) setIsLoading(false);
|
| 130 |
+
}
|
| 131 |
+
}, []);
|
| 132 |
+
|
| 133 |
+
useEffect(() => {
|
| 134 |
+
fetchLogs();
|
| 135 |
+
}, [fetchLogs]);
|
| 136 |
+
|
| 137 |
+
// --- Stable callbacks for background animations ---
|
| 138 |
+
const handleExplodeComplete = useCallback(() => {
|
| 139 |
+
setTriggerExplodeAnim(false);
|
| 140 |
+
}, []); // No dependencies needed as setTriggerExplodeAnim is stable
|
| 141 |
+
|
| 142 |
+
const handleTossComplete = useCallback(() => {
|
| 143 |
+
setTriggerTossAnim(false);
|
| 144 |
+
}, []); // No dependencies needed as setTriggerTossAnim is stable
|
| 145 |
+
|
| 146 |
+
// --- Event Handlers ---
|
| 147 |
+
|
| 148 |
+
// Handle node selection from react-arborist
|
| 149 |
+
const handleSelect = (nodes) => {
|
| 150 |
+
setSelectedNodes(nodes.map(n => n.data)); // Store the data object
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
// Double click tracking
|
| 154 |
+
const handleNodeClick = (id, time) => {
|
| 155 |
+
setLastClick({ id, time });
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const handleRefresh = () => {
|
| 159 |
+
setSelectedNodes([]); // Clear selection on refresh
|
| 160 |
+
treeRef.current?.deselectAll(); // Deselect in tree
|
| 161 |
+
fetchLogs(true); // Show loading indicator during manual refresh
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const handleDeleteSelected = async () => {
|
| 165 |
+
if (selectedNodes.length === 0 || isDeleting || isClearing) return;
|
| 166 |
+
|
| 167 |
+
const filesToDelete = selectedNodes.map(node => ({ filename: node.name, taskId: node.taskId })); // Get filenames and task IDs
|
| 168 |
+
|
| 169 |
+
// Trigger background animation *before* starting async operation
|
| 170 |
+
setTriggerTossAnim(true);
|
| 171 |
+
|
| 172 |
+
setIsDeleting(true);
|
| 173 |
+
setModalError(null);
|
| 174 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 175 |
+
let successCount = 0;
|
| 176 |
+
let firstError = null;
|
| 177 |
+
|
| 178 |
+
const results = await Promise.allSettled(
|
| 179 |
+
filesToDelete.map(file => {
|
| 180 |
+
if (!file.taskId) {
|
| 181 |
+
return Promise.reject(new Error(`Invalid filename format: ${file.filename}`));
|
| 182 |
+
}
|
| 183 |
+
return deleteLogFile(file.taskId); // Use taskId from selected node data
|
| 184 |
+
})
|
| 185 |
+
);
|
| 186 |
+
|
| 187 |
+
results.forEach((result, index) => {
|
| 188 |
+
const file = filesToDelete[index];
|
| 189 |
+
if (result.status === 'fulfilled') {
|
| 190 |
+
successCount++;
|
| 191 |
+
console.log(`Successfully deleted ${file.filename}:`, result.value);
|
| 192 |
+
} else {
|
| 193 |
+
console.error(`Failed to delete ${file.filename}:`, result.reason);
|
| 194 |
+
if (!firstError) {
|
| 195 |
+
firstError = result.reason?.message || `Failed to delete ${file.filename}.`;
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
setIsDeleting(false);
|
| 201 |
+
|
| 202 |
+
if (firstError) {
|
| 203 |
+
setModalError(`Error: ${firstError}${successCount > 0 ? ` (${successCount} log(s) deleted successfully)` : ''}`);
|
| 204 |
+
} else {
|
| 205 |
+
setActionStatus({ message: `Successfully deleted ${successCount} log file(s).`, error: false, success: true });
|
| 206 |
+
setIsDeleteModalOpen(false); // Close modal on full success
|
| 207 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 5000);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// Refresh log list if any deletions succeeded
|
| 211 |
+
if (successCount > 0) {
|
| 212 |
+
setSelectedNodes([]); // Clear selection state
|
| 213 |
+
treeRef.current?.deselectAll(); // Deselect in tree instance
|
| 214 |
+
await fetchLogs(false); // Refresh list without full loading indicator
|
| 215 |
+
}
|
| 216 |
+
// Reset trigger after a short delay (allows animation to start)
|
| 217 |
+
// setTimeout(() => setTriggerTossAnim(false), 100); // Resetting is handled by the callback now
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
const handleClearAll = async () => {
|
| 221 |
+
if (isClearing || isDeleting) return;
|
| 222 |
+
|
| 223 |
+
// Trigger background animation *before* starting async operation
|
| 224 |
+
setTriggerExplodeAnim(true);
|
| 225 |
+
|
| 226 |
+
setIsClearing(true);
|
| 227 |
+
setModalError(null);
|
| 228 |
+
setClearAllResult(null);
|
| 229 |
+
setActionStatus({ message: '', error: false, success: false });
|
| 230 |
+
|
| 231 |
+
try {
|
| 232 |
+
const result = await clearAllLogs();
|
| 233 |
+
setClearAllResult(result); // Store the result { message, errors }
|
| 234 |
+
// Display the primary message from the API response
|
| 235 |
+
setActionStatus({ message: result.message || 'Cleared logs.', error: result.errors && result.errors.length > 0, success: !result.errors || result.errors.length === 0 });
|
| 236 |
+
if (!result.errors || result.errors.length === 0) {
|
| 237 |
+
setIsClearAllModalOpen(false); // Close modal only if no errors reported by API
|
| 238 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 5000);
|
| 239 |
+
} else {
|
| 240 |
+
// If there were errors, keep the modal open and display them via clearAllResult
|
| 241 |
+
setModalError(`Clear operation reported ${result.errors.length} error(s). See details below.`);
|
| 242 |
+
}
|
| 243 |
+
setSelectedNodes([]); // Clear selection
|
| 244 |
+
treeRef.current?.deselectAll();
|
| 245 |
+
await fetchLogs(false); // Refresh list
|
| 246 |
+
} catch (e) {
|
| 247 |
+
console.error("Failed to clear all logs:", e);
|
| 248 |
+
// Display error within the modal
|
| 249 |
+
setModalError(e.message || 'Could not clear all log files.');
|
| 250 |
+
// Also update general status bar temporarily
|
| 251 |
+
setActionStatus({ message: `Error clearing logs: ${e.message}`, error: true, success: false });
|
| 252 |
+
setTimeout(() => setActionStatus({ message: '', error: false, success: false }), 8000);
|
| 253 |
+
} finally {
|
| 254 |
+
setIsClearing(false);
|
| 255 |
+
// Reset trigger after a short delay (allows animation to start)
|
| 256 |
+
// setTimeout(() => setTriggerExplodeAnim(false), 100); // Resetting is handled by the callback now
|
| 257 |
+
}
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
// --- Modal Controls ---
|
| 261 |
+
const openDeleteModal = () => {
|
| 262 |
+
setModalError(null);
|
| 263 |
+
setIsDeleteModalOpen(true);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
const openClearAllModal = () => {
|
| 267 |
+
setModalError(null);
|
| 268 |
+
setClearAllResult(null);
|
| 269 |
+
setIsClearAllModalOpen(true);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Function to clear selection when clicking outside the tree or action buttons
|
| 273 |
+
const handleBackgroundClick = useCallback((e) => {
|
| 274 |
+
// Don't clear if a modal is open
|
| 275 |
+
if (isDeleteModalOpen || isClearAllModalOpen) return;
|
| 276 |
+
|
| 277 |
+
// Don't clear if the click originated from the document stack background
|
| 278 |
+
const clickedOnBackground = e.target.closest('[data-id="document-stack-background"]');
|
| 279 |
+
|
| 280 |
+
const clickedOnRow = e.target.closest('[role="treeitem"]');
|
| 281 |
+
const clickedOnScrollbar = e.target.closest('.simplebar-scrollbar'); // Adjust if using a specific scrollbar library
|
| 282 |
+
const clickedOnActionButton = e.target.closest('[data-action-button="true"]');
|
| 283 |
+
const clickedOnModal = e.target.closest('[role="dialog"]'); // Check if click is inside any modal
|
| 284 |
+
|
| 285 |
+
if (!clickedOnRow && !clickedOnScrollbar && !clickedOnActionButton && !clickedOnModal && !clickedOnBackground) {
|
| 286 |
+
if (treeRef.current?.deselectAll) {
|
| 287 |
+
treeRef.current.deselectAll();
|
| 288 |
+
}
|
| 289 |
+
if (typeof setSelectedNodes === 'function') {
|
| 290 |
+
setSelectedNodes([]);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
}, [isDeleteModalOpen, isClearAllModalOpen]);
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
// --- Render ---
|
| 297 |
+
const actionButtonClass = "flex items-center gap-2 px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
| 298 |
+
|
| 299 |
+
return (
|
| 300 |
+
<div className="flex flex-col h-full p-4 relative" onMouseDown={handleBackgroundClick}> {/* Added relative positioning */}
|
| 301 |
+
<DocumentStackBackground
|
| 302 |
+
triggerExplode={triggerExplodeAnim}
|
| 303 |
+
triggerTossTop={triggerTossAnim}
|
| 304 |
+
onExplodeComplete={handleExplodeComplete} // Use stable callback
|
| 305 |
+
onTossComplete={handleTossComplete} // Use stable callback
|
| 306 |
+
/>
|
| 307 |
+
{/* Background Animation */}
|
| 308 |
+
|
| 309 |
+
{/* Content Wrapper (needs z-index) */}
|
| 310 |
+
<div className="relative z-10 flex flex-col flex-grow">
|
| 311 |
+
|
| 312 |
+
{/* Header */}
|
| 313 |
+
<div className="flex justify-between items-center mb-4 bg-gray-900 bg-opacity-50 backdrop-blur-sm p-4 rounded-t-lg border-b border-gray-700">
|
| 314 |
+
<h1 className="text-2xl font-bold text-gray-100 flex items-center gap-2"><FiList /> Log Manager</h1>
|
| 315 |
+
<div className="flex items-center gap-2">
|
| 316 |
+
<button
|
| 317 |
+
onClick={handleRefresh}
|
| 318 |
+
className={actionButtonClass}
|
| 319 |
+
title="Refresh log list"
|
| 320 |
+
disabled={isLoading || isDeleting || isClearing}
|
| 321 |
+
data-action-button="true"
|
| 322 |
+
>
|
| 323 |
+
<FiRefreshCw className={isLoading ? 'animate-spin' : ''} /> Refresh
|
| 324 |
+
</button>
|
| 325 |
+
<EnhancedButton
|
| 326 |
+
onClick={openDeleteModal}
|
| 327 |
+
className={`${actionButtonClass} text-orange-400 hover:bg-orange-900 hover:text-orange-300
|
| 328 |
+
${selectedNodes.length > 0 && !isDeleting && !isClearing ? 'animate-pulse-orange' : ''} `} // Added pulse animation
|
| 329 |
+
title="Delete Selected Log Files"
|
| 330 |
+
disabled={selectedNodes.length === 0 || isLoading || isDeleting || isClearing}
|
| 331 |
+
data-action-button="true"
|
| 332 |
+
particleColor="#fb923c"
|
| 333 |
+
>
|
| 334 |
+
<FiTrash2 /> Delete Selected ({selectedNodes.length})
|
| 335 |
+
</EnhancedButton>
|
| 336 |
+
<EnhancedButton
|
| 337 |
+
onClick={openClearAllModal}
|
| 338 |
+
className={`${actionButtonClass} text-red-400 hover:bg-red-900 hover:text-red-300
|
| 339 |
+
${treeData.length > 0 && selectedNodes.length === 0 && !isDeleting && !isClearing ? 'animate-pulse-red-intense' : ''} `} // MODIFIED: Added selectedNodes.length === 0 condition for pulse
|
| 340 |
+
title="Delete ALL Log Files"
|
| 341 |
+
disabled={treeData.length === 0 || isLoading || isDeleting || isClearing}
|
| 342 |
+
data-action-button="true"
|
| 343 |
+
particleColor="#ef4444"
|
| 344 |
+
>
|
| 345 |
+
<FiSlash /> Clear All Logs
|
| 346 |
+
</EnhancedButton>i
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
{/* Loading/Error States for Initial Load */}
|
| 351 |
+
{isLoading && (
|
| 352 |
+
<div className="flex-grow flex items-center justify-center text-gray-400 bg-gray-900 bg-opacity-70 backdrop-blur-sm p-4 rounded-b-lg">
|
| 353 |
+
<FiLoader className="animate-spin mr-3" /> Loading log files...
|
| 354 |
+
</div>
|
| 355 |
+
)}
|
| 356 |
+
{!isLoading && error && (
|
| 357 |
+
<div className="flex-grow flex flex-col items-center justify-center text-red-400 bg-red-900 bg-opacity-60 backdrop-blur-sm p-6 rounded-b-lg border border-red-700">
|
| 358 |
+
<FiAlertTriangle className="w-10 h-10 mb-2" />
|
| 359 |
+
<p>Error loading log files:</p>
|
| 360 |
+
<p className="text-sm mt-1">{error}</p>
|
| 361 |
+
<button onClick={handleRefresh} className="mt-4 px-3 py-1.5 rounded bg-gray-600 hover:bg-gray-500 text-gray-200 text-sm">
|
| 362 |
+
Retry
|
| 363 |
+
</button>
|
| 364 |
+
</div>
|
| 365 |
+
)}
|
| 366 |
+
{/* Tree View Area */}
|
| 367 |
+
{!isLoading && !error && (
|
| 368 |
+
<div className="flex-grow border border-gray-700 rounded-b-lg bg-gray-900 bg-opacity-50 backdrop-blur-sm overflow-auto relative min-h-[200px]">
|
| 369 |
+
{treeData.length > 0 ? (
|
| 370 |
+
<Tree
|
| 371 |
+
ref={treeRef}
|
| 372 |
+
data={treeData}
|
| 373 |
+
openByDefault={false} // Not applicable for flat list
|
| 374 |
+
width="100%"
|
| 375 |
+
height={600} // Use a fixed numeric height
|
| 376 |
+
// Make height dynamic based on container? Or set fixed like other pages?
|
| 377 |
+
// height={600} // Fixed height example
|
| 378 |
+
indent={24}
|
| 379 |
+
rowHeight={32}
|
| 380 |
+
paddingTop={10}
|
| 381 |
+
paddingBottom={10}
|
| 382 |
+
onSelect={handleSelect}
|
| 383 |
+
disableDrop={true} // Cannot drop onto logs
|
| 384 |
+
disableDrag={true} // Cannot drag logs (no move endpoint)
|
| 385 |
+
// Allow multiple selection (default behavior often includes shift/ctrl/meta keys)
|
| 386 |
+
// selectionFollowsFocus={true} // Consider if needed for keyboard nav + selection
|
| 387 |
+
>
|
| 388 |
+
{/* Pass props needed by Node, including click tracking and navigate */}
|
| 389 |
+
{(props) => <Node {...props} navigate={navigate} onNodeClick={handleNodeClick} lastClick={lastClick} />}
|
| 390 |
+
</Tree>
|
| 391 |
+
) : (
|
| 392 |
+
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
| 393 |
+
<FiList className="w-12 h-12 mb-4 text-gray-600" />
|
| 394 |
+
<p>No log files found in <code className="text-xs bg-gray-600 px-1 rounded">./logs/</code> directory.</p>
|
| 395 |
+
</div>
|
| 396 |
+
)}
|
| 397 |
+
</div>
|
| 398 |
+
)}
|
| 399 |
+
|
| 400 |
+
{/* Status Bar Area */}
|
| 401 |
+
<div className="h-8 mt-2 text-sm flex items-center gap-4 overflow-hidden whitespace-nowrap px-4 py-1 bg-gray-900 bg-opacity-80 backdrop-blur-sm rounded-lg border border-gray-700">
|
| 402 |
+
{/* Deleting Status */}
|
| 403 |
+
{isDeleting && (
|
| 404 |
+
<div className="flex items-center text-orange-400">
|
| 405 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> Deleting selected logs...
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
+
{/* Clearing Status */}
|
| 409 |
+
{isClearing && (
|
| 410 |
+
<div className="flex items-center text-red-400">
|
| 411 |
+
<FiLoader className="animate-spin mr-2 flex-shrink-0" /> Clearing all logs...
|
| 412 |
+
</div>
|
| 413 |
+
)}
|
| 414 |
+
{/* General Action Status */}
|
| 415 |
+
{actionStatus.message && !isDeleting && !isClearing && (
|
| 416 |
+
<p className={`${actionStatus.error ? 'text-red-400' : actionStatus.success ? 'text-green-400' : 'text-gray-400'} truncate flex items-center`} title={actionStatus.message}>
|
| 417 |
+
{actionStatus.success && <FiCheckCircle className="mr-1 flex-shrink-0"/>}
|
| 418 |
+
{actionStatus.error && <FiAlertTriangle className="mr-1 flex-shrink-0"/>}
|
| 419 |
+
{actionStatus.message}
|
| 420 |
+
</p>
|
| 421 |
+
)}
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
{/* --- Modals --- */}
|
| 426 |
+
|
| 427 |
+
{/* Delete Confirmation Modal */}
|
| 428 |
+
<Modal
|
| 429 |
+
isOpen={isDeleteModalOpen}
|
| 430 |
+
onClose={() => !(isDeleting || isClearing) && setIsDeleteModalOpen(false)}
|
| 431 |
+
title={`Confirm Delete ${selectedNodes.length} Log File(s)`} // Use selectedNodes.length
|
| 432 |
+
>
|
| 433 |
+
<p className="mb-4">Are you sure you want to permanently delete the selected {selectedNodes.length} log file(s)?</p>
|
| 434 |
+
<ul className="list-disc list-inside mb-4 max-h-40 overflow-y-auto bg-gray-700 p-2 rounded border border-gray-600 font-mono text-xs">
|
| 435 |
+
{/* Use selectedNodes for list */}
|
| 436 |
+
{selectedNodes.sort((a, b) => a.name.localeCompare(b.name)).map(node => <li key={node.id} className="truncate py-0.5">{node.name}</li>)}
|
| 437 |
+
</ul>
|
| 438 |
+
{modalError && (
|
| 439 |
+
<p className="text-red-400 text-sm mb-3 bg-red-900 bg-opacity-40 p-2 rounded border border-red-700">{modalError}</p>
|
| 440 |
+
)}
|
| 441 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 442 |
+
<button
|
| 443 |
+
onClick={() => setIsDeleteModalOpen(false)}
|
| 444 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200"
|
| 445 |
+
disabled={isDeleting || isClearing}
|
| 446 |
+
>
|
| 447 |
+
Cancel
|
| 448 |
+
</button>
|
| 449 |
+
<EnhancedButton
|
| 450 |
+
onClick={handleDeleteSelected}
|
| 451 |
+
className={`px-4 py-2 rounded bg-orange-600 hover:bg-orange-500 text-white flex items-center disabled:opacity-50
|
| 452 |
+
${isDeleteModalOpen && !isDeleting && !isClearing && selectedNodes.length > 0 ? 'animate-pulse-orange' : ''}`}
|
| 453 |
+
disabled={isDeleting || isClearing || selectedNodes.length === 0} // Use selectedNodes.length
|
| 454 |
+
particleColor="#fb923c"
|
| 455 |
+
>
|
| 456 |
+
{isDeleting ? <FiLoader className="animate-spin mr-2" /> : <FiTrash2 className="mr-1" />}
|
| 457 |
+
{isDeleting ? 'Deleting...' : `Delete Selected`}
|
| 458 |
+
</EnhancedButton>
|
| 459 |
+
</div>
|
| 460 |
+
</Modal>
|
| 461 |
+
|
| 462 |
+
{/* Clear All Confirmation Modal */}
|
| 463 |
+
<Modal
|
| 464 |
+
isOpen={isClearAllModalOpen}
|
| 465 |
+
onClose={() => !(isDeleting || isClearing) && setIsClearAllModalOpen(false)}
|
| 466 |
+
title="Confirm Clear All Logs"
|
| 467 |
+
>
|
| 468 |
+
{/* Use treeData.length for total count */}
|
| 469 |
+
<p className="mb-4">Are you sure you want to permanently delete <strong>ALL</strong> ({treeData.length}) log files in the <code className="text-xs bg-gray-600 px-1 rounded">./logs/</code> directory? This action cannot be undone.</p>
|
| 470 |
+
{/* Display API-reported errors if any occurred during the clear operation */}
|
| 471 |
+
{clearAllResult?.errors && clearAllResult.errors.length > 0 && (
|
| 472 |
+
<div className="mb-4">
|
| 473 |
+
<p className="text-yellow-400 text-sm mb-2">The following errors occurred during the clear operation:</p>
|
| 474 |
+
<ul className="list-disc list-inside max-h-32 overflow-y-auto bg-gray-700 p-2 rounded border border-gray-600 text-xs">
|
| 475 |
+
{clearAllResult.errors.map((err, index) => (
|
| 476 |
+
<li key={index} className="truncate py-0.5">{typeof err === 'string' ? err : JSON.stringify(err)}</li>
|
| 477 |
+
))}
|
| 478 |
+
</ul>
|
| 479 |
+
</div>
|
| 480 |
+
)}
|
| 481 |
+
{/* General modal error display */}
|
| 482 |
+
{modalError && (
|
| 483 |
+
<p className={`text-sm mb-3 bg-opacity-40 p-2 rounded border ${clearAllResult?.errors?.length > 0 ? 'bg-yellow-900 border-yellow-700 text-yellow-300' : 'bg-red-900 border-red-700 text-red-300' }`}>{modalError}</p>
|
| 484 |
+
)}
|
| 485 |
+
<div className="flex justify-end gap-3 mt-4">
|
| 486 |
+
<button
|
| 487 |
+
onClick={() => setIsClearAllModalOpen(false)}
|
| 488 |
+
className="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500 text-gray-200"
|
| 489 |
+
disabled={isDeleting || isClearing}
|
| 490 |
+
>
|
| 491 |
+
{clearAllResult?.errors?.length > 0 ? 'Close' : 'Cancel'} {/* Change label if errors occurred */}
|
| 492 |
+
</button>
|
| 493 |
+
{/* Only show the 'Clear All' button if no errors occurred or before the attempt */}
|
| 494 |
+
{(!clearAllResult || !clearAllResult.errors || clearAllResult.errors.length === 0) && (
|
| 495 |
+
<EnhancedButton
|
| 496 |
+
onClick={handleClearAll}
|
| 497 |
+
className={`px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white flex items-center disabled:opacity-50
|
| 498 |
+
${isClearAllModalOpen && !isDeleting && !isClearing && treeData.length > 0 ? 'animate-pulse-red-intense' : ''}`}
|
| 499 |
+
// Use treeData.length for disable check
|
| 500 |
+
disabled={isDeleting || isClearing || treeData.length === 0}
|
| 501 |
+
particleColor="#ef4444"
|
| 502 |
+
>
|
| 503 |
+
{isClearing ? <FiLoader className="animate-spin mr-2" /> : <FiSlash className="mr-1" />}
|
| 504 |
+
{isClearing ? 'Clearing...' : `Clear All Logs`}
|
| 505 |
+
</EnhancedButton>
|
| 506 |
+
)}
|
| 507 |
+
</div>
|
| 508 |
+
</Modal>
|
| 509 |
+
|
| 510 |
+
</div>
|
| 511 |
+
);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
export default LogManager;
|