Leon4gr45 commited on
Commit
42da440
·
verified ·
1 Parent(s): d863eec

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/ocr.yml +21 -0
  2. .gitignore +26 -0
  3. .hfignore +48 -0
  4. .pytest_cache/.gitignore +2 -0
  5. .pytest_cache/CACHEDIR.TAG +4 -0
  6. .pytest_cache/README.md +8 -0
  7. .pytest_cache/v/cache/lastfailed +1 -0
  8. .pytest_cache/v/cache/nodeids +493 -0
  9. Agent.md +154 -0
  10. Dockerfile +23 -0
  11. LICENSE +21 -0
  12. README.md +284 -10
  13. api.py +737 -0
  14. api_README.md +500 -0
  15. atk-interface/README.md +12 -0
  16. atk-interface/api_README.md +500 -0
  17. atk-interface/eslint.config.js +33 -0
  18. atk-interface/index.html +13 -0
  19. atk-interface/package-lock.json +0 -0
  20. atk-interface/package.json +38 -0
  21. atk-interface/pages_README.md +134 -0
  22. atk-interface/postcss.config.js +6 -0
  23. atk-interface/public/vite.svg +1 -0
  24. atk-interface/src/.gitignore +24 -0
  25. atk-interface/src/App.css +42 -0
  26. atk-interface/src/App.jsx +40 -0
  27. atk-interface/src/api.js +490 -0
  28. atk-interface/src/assets/atk-logo-notscuffed.png +0 -0
  29. atk-interface/src/assets/react.svg +1 -0
  30. atk-interface/src/components/ButtonParticles.jsx +79 -0
  31. atk-interface/src/components/DocumentStackBackground.jsx +315 -0
  32. atk-interface/src/components/EnhancedButton.jsx +96 -0
  33. atk-interface/src/components/FireworksBackground.jsx +261 -0
  34. atk-interface/src/components/IdleMonitorGraphic.jsx +65 -0
  35. atk-interface/src/components/Layout.jsx +147 -0
  36. atk-interface/src/components/Modal.jsx +67 -0
  37. atk-interface/src/components/PourOverBackground.jsx +224 -0
  38. atk-interface/src/components/ProgressBarParticles.jsx +384 -0
  39. atk-interface/src/components/ScrollingTextBackground.jsx +92 -0
  40. atk-interface/src/context/FileManagerContext.jsx +112 -0
  41. atk-interface/src/context/NavigationContext.jsx +19 -0
  42. atk-interface/src/context/TaskHistoryContext.jsx +60 -0
  43. atk-interface/src/index.css +3 -0
  44. atk-interface/src/main.jsx +16 -0
  45. atk-interface/src/pages/Chat.jsx +550 -0
  46. atk-interface/src/pages/ConfigEditor.jsx +595 -0
  47. atk-interface/src/pages/ConfigSelector.jsx +1011 -0
  48. atk-interface/src/pages/HomePage.jsx +21 -0
  49. atk-interface/src/pages/InputManager.jsx +992 -0
  50. 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: Agent 0
3
- emoji: 📈
4
- colorFrom: purple
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![](images/augmentoolkit-logo.png)
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
+ [![DOI](https://zenodo.org/badge/726083337.svg)](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;