227 Commits

Author SHA1 Message Date
2462356922 chore(hq): daily sync 2026-04-06 2026-04-06 09:00:57 +00:00
339754ca3c chore(hq): daily sync 2026-04-02 2026-04-02 09:00:44 +00:00
b45896a391 chore(hq): daily sync 2026-04-01 2026-04-01 09:00:44 +00:00
4341215af2 chore(hq): daily sync 2026-03-28 2026-03-28 09:00:40 +00:00
540d97a7e1 chore(hq): daily sync 2026-03-27 2026-03-27 09:00:44 +00:00
fcc716db95 chore(hq): daily sync 2026-03-26 2026-03-26 09:00:36 +00:00
4d09ce2a2e chore(hq): daily sync 2026-03-20 2026-03-20 09:00:26 +00:00
f14cbfd6aa chore(hq): daily sync 2026-03-12 2026-03-12 09:00:33 +00:00
cc683de192 chore(hq): daily sync 2026-03-11 2026-03-11 09:00:32 +00:00
d17611eec0 chore(hq): daily sync 2026-03-10 2026-03-10 09:00:27 +00:00
075ad36221 feat(V&V): Phase 2 — prysm cross-validation + report figure generator
- cross_validate_prysm.py: 4 tests against prysm v0.21.1
- Noll convention variant identified and documented (sin/cos swap on paired modes)
- Aberration magnitudes agree to <1e-13 nm (machine precision)
- RMS WFE agreement: 1.4e-14 nm difference
- generate_validation_report.py: Creates 9 publication-quality figures
- Figures output to PKM project folder

Phase 2 conclusion: Atomizer Zernike implementation is verified correct.
2026-03-09 16:03:42 +00:00
4146e9d8f1 feat(V&V): Updated to FEA CSV format + real M2 mesh injection
- Output now matches WFE_from_CSV_OPD format: ,X,Y,Z,DX,DY,DZ (meters)
- Suite regenerated using real M2 mesh (357 nodes, 308mm diameter)
- All 14 clean test cases: PASS (0.000 nm error)
- 3 noisy cases: expected FAIL due to low node count amplifying noise
- Added --inject mode to use real FEA mesh geometry
- Added lateral displacement test case
2026-03-09 15:56:23 +00:00
f9373bee99 feat(V&V): Zernike pipeline validation - synthetic WFE generator + round-trip validator
- generate_synthetic_wfe.py: Creates synthetic OPD surfaces from known Zernike coefficients
- validate_zernike_roundtrip.py: Round-trip validation (generate → fit → compare)
- validation_suite/: 18 test cases (single mode, multi-mode, noisy, edge cases)
- All 18 test cases pass with 0.000 nm error (clean) and <0.3 nm (10nm noise)
- M1 params: 1200mm dia, 135.75mm inner radius, 50 Noll modes

Project: P-Zernike-Validation (GigaBIT M1)
Requested by: Adyn Miles (StarSpec) for risk reduction before M2/M3 ordering
2026-03-09 15:49:06 +00:00
9b0769f3f4 chore(hq): daily sync 2026-03-09 2026-03-09 09:00:23 +00:00
11d212a476 chore(hq): daily sync 2026-03-08 2026-03-08 09:00:23 +00:00
b3162aa78d Tier 2 dev workflow: Windows test runner + result sync
- run_tests.bat: double-click test runner with JSON result capture
- run_script.bat: run any script with output capture
- test_results/ folder for Syncthing-based result sharing
- Auto-mark NX-dependent tests for --quick mode
- pytest-json-report for structured results
2026-03-07 14:07:32 +00:00
a069a9f21f chore(hq): daily sync 2026-03-07 2026-03-07 10:00:24 +00:00
ae120c653e chore(hq): daily sync 2026-03-06 2026-03-06 10:00:27 +00:00
1b83159050 Fix NX path to DesigncenterNX2512 2026-03-05 15:34:55 +00:00
c930728b1c Add GigaBIT M1 frame stiffness characterization project
- 3 studies: sensitivity (Method D), stiffness sweep (Method B), robustness
- Atomizer specs with run matrices, Zernike annular OPD extractor
- War-room validated hybrid B+D method
2026-03-05 15:34:41 +00:00
d299e168a3 chore(hq): daily sync 2026-03-05 2026-03-05 10:00:22 +00:00
a6765d8a1f chore(hq): daily sync 2026-03-04 2026-03-04 10:00:20 +00:00
119011b420 chore(hq): daily sync 2026-03-02 2026-03-02 10:05:24 +00:00
cf29e0aba5 chore(hq): daily sync 2026-03-01 2026-03-01 10:00:23 +00:00
1873e1865c chore(hq): daily sync 2026-02-28 2026-02-28 10:00:23 +00:00
25c415b52f chore(hq): daily sync 2026-02-27 2026-02-27 10:00:23 +00:00
6b17d73ef7 chore(hq): daily sync 2026-02-26 2026-02-26 10:00:21 +00:00
074632d0a9 chore(hq): daily sync 2026-02-25 2026-02-25 10:00:23 +00:00
b448ca6268 auto: daily sync 2026-02-25 08:00:14 +00:00
2026572d91 chore(hq): daily sync 2026-02-24 2026-02-24 10:00:18 +00:00
c7ef38282f auto: daily sync 2026-02-24 08:00:09 +00:00
1f58bb8016 chore(hq): daily sync 2026-02-23 2026-02-23 10:00:17 +00:00
31d21ec551 chore(hq): daily sync 2026-02-22 2026-02-22 10:00:18 +00:00
2b976cf872 chore(hq): daily sync 2026-02-21 2026-02-21 10:00:16 +00:00
39212aaf81 auto: daily sync 2026-02-21 08:00:14 +00:00
7acda7f55f chore(hq): daily sync 2026-02-20 2026-02-20 10:00:13 +00:00
c59072eff2 auto: daily sync 2026-02-20 08:00:17 +00:00
176b75328f chore(hq): daily sync 2026-02-19 2026-02-19 10:00:18 +00:00
7eb3d11f02 auto: daily sync 2026-02-19 08:00:36 +00:00
6658de02f4 feat(isogrid): FEA stress field → 2D heatmap → adaptive density feedback
Closes the optimization loop: OP2 results → density field refinement.

**extract_stress_field_2d.py (new)**
- Reads OP2 (3D solid or 2D shell elements) + BDF via pyNastran
- Projects element centroids to 2D sandbox coords using geometry transform
- Averages stress through thickness (for solid 3D meshes)
- Normalises by sigma_yield to [0..1]
- save/load helpers (NPZ) for trial persistence

**stress_feedback.py (new)**
- StressFeedbackField: converts 2D stress scatter → smooth density modifier
- Gaussian blur (configurable radius, default 40mm) prevents oscillations
- RBF interpolator (thin-plate spline) for fast pointwise evaluation
- evaluate(x, y) returns S_stress ∈ [0..1]
- from_field() and from_npz() constructors

**density_field.py (modified)**
- evaluate_density() now accepts optional stress_field= argument
- Adaptive formula: η = η₀ + α·I + β·E + γ·S_stress
- gamma_stress param controls feedback gain (0.0 = pure parametric)
- Fully backward compatible (no stress_field = original behaviour)

Usage:
    field = extract_stress_field_2d(op2, bdf, geometry["transform"], sigma_yield=276.0)
    feedback = StressFeedbackField.from_field(field, blur_radius_mm=40.0)
    eta = evaluate_density(x, y, geometry, params, stress_field=feedback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 11:13:28 -05:00
a9c40368d3 feat(isogrid): Add DRAW_HOLES flag to skip bolt holes in NX import
Default: DRAW_HOLES = False (holes already exist in the solid body).

Config block in import_profile.py is now:
  DRAW_OUTER_BOUNDARY = False  (sandbox perimeter — not needed for subtract)
  DRAW_HOLES          = False  (bolt holes — already in existing body)

Sketch now imports ONLY the rib pocket profiles, ready for Subtract extrude.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 09:49:08 -05:00
98774453b3 feat(isogrid): Skip outer boundary in NX sketch import (subtract workflow)
Add DRAW_OUTER_BOUNDARY flag (default: False) to import_profile.py.

When False (default): only pocket profiles + holes are imported into the
sketch. This is the correct mode when subtracting rib pockets from an
existing solid body — the sandbox perimeter is not needed and would create
unwanted edges in the part.

When True: full profile including sandbox perimeter (original behavior,
for standalone plate creation only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 09:46:07 -05:00
d8570eaa2d chore(hq): daily sync 2026-02-18 2026-02-18 10:00:34 +00:00
68a6b4763b auto: daily sync 2026-02-18 08:00:16 +00:00
8efa8ba0d1 feat(isogrid): Add update-in-place NX import + 3 density field variations
Major improvements to NX import workflow and rib pattern generation:

**NX Import (import_profile.py)**
- Smart sketch management: detects existing sketches and updates in-place
- Preserves extrude references (no manual re-reference needed!)
- First run: creates new sketch + auto-extrude
- Subsequent runs: clears geometry, redraws, extrude regenerates automatically
- Added _find_sketch_by_name() and _clear_sketch_geometry() functions

**Rib Pattern Variations**
Generated 3 different density field strategies for testing NX updates:
- Balanced (α=1.0, β=0.3): Original moderate density - 86 pockets, 2,499g
- Edge-focused (α=0.3, β=1.5): Dense ribs near boundaries - 167 pockets, 2,328g
- Hole-focused (α=1.8, β=0.15): Dense around holes - 62 pockets, 3,025g

**New Files**
- import_profile_update_test.py: Standalone update-only test script
- params_large_triangles.json: s_min=30mm, s_max=100mm (larger triangles)
- params_edge_focused.json: β=1.5 (boundary reinforcement)
- params_hole_focused.json: α=1.8 (hole reinforcement)
- sandbox_results/{edge_focused,hole_focused}/: Complete rib profile sets

**Test Results (Sandbox 1)**
- 833 triangles with large triangle params (vs 1,501 with previous params)
- Edge-focused: 1,155 triangles, 167 pockets (2x denser)
- Hole-focused: 523 triangles, 62 pockets (sparse pattern)

This enables rapid rib pattern iteration in NX without losing extrude references!

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 22:02:41 -05:00
6ed074dbbf feat(isogrid): Finalize Gmsh Frontal-Delaunay as production mesher
Archive Triangle library implementation and establish Gmsh as the official
production default for adaptive isogrid generation.

## Changes

**Production Pipeline:**
- Gmsh Frontal-Delaunay now the sole production mesher
- Removed Triangle library from active codebase (archived for reference)
- Updated all imports and documentation to reflect Gmsh as default

**Archived:**
- Moved `src/brain/triangulation.py` to `archive/deprecated-triangle-mesher/`
- Added deprecation README explaining why Gmsh replaced Triangle

**Validation Results:**
- Sandbox 1 (complex L-bracket, 16 holes): 1,501 triangles, 212 pockets
  - Adaptive density: Perfect response to hole weights (0.28-0.84)
  - Min angle: 1.4° (complex corners), Mean: 60.0° (equilateral)
  - Boundary conformance: Excellent (notches, L-junctions)

- Sandbox 2 (H-bracket, no holes): 342 triangles, 47 pockets
  - Min angle: 1.0°, Mean: 60.0°
  - Clean rounded corner handling

**Performance:**
- Single-pass meshing (<2 sec for 1500 triangles)
- Background size fields (no iterative refinement)
- Better triangle quality (30-35° min angles vs 25-30° with Triangle)

**Why Gmsh Won:**
1. Natural boundary conformance (Frontal-Delaunay advances from edges)
2. Single-pass adaptive sizing (vs 3+ iterations with Triangle)
3. Boolean hole operations (vs PSLG workarounds)
4. More manufacturable patterns (equilateral bias, uniform ribs)
5. Cleaner code (no aggressive post-filtering needed)

**Documentation:**
- Updated README.md: Gmsh as production default
- Updated technical-spec.md: Gmsh pipeline details
- Added archive/deprecated-triangle-mesher/README.md

**Testing:**
- Added visualize_sandboxes.py for comprehensive validation
- Generated density overlays, rib profiles, angle distributions
- Cleaned up test artifacts (lloyd_trial_output, comparison_output)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 20:40:10 -05:00
5c63d877f0 feat: Switch isogrid to Gmsh Frontal-Delaunay meshing (production default)
Replaces Triangle library with Gmsh as the default triangulation engine for
adaptive isogrid generation. Gmsh's Frontal-Delaunay algorithm provides:

- Better adaptive density response (concentric rings around holes)
- Superior triangle quality (min angles 30-35° vs 25-30°)
- Single-pass meshing with background size fields (vs iterative refinement)
- More equilateral triangles → uniform rib widths, better manufacturability
- Natural boundary conformance → cleaner frame edges

Comparison results (mixed hole weights plate):
- Min angle improvement: +5.1° (25.7° → 30.8°)
- Density field accuracy: Excellent vs Poor
- Visual quality: Concentric hole refinement vs random patterns

Changes:
- Updated src/brain/__main__.py to import triangulation_gmsh
- Added gmsh>=4.11 to requirements.txt (Triangle kept as fallback)
- Updated README and technical-spec.md
- Added comparison script and test results

Triangle library remains available as fallback option.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 17:05:19 -05:00
906037f974 feat(adaptive-isogrid): add Gmsh Frontal-Delaunay triangulation
- Replaces scipy/Triangle iterative refinement with single-pass Gmsh
- Separate distance fields for holes (I(x)) and edges (E(x))
- Frontal-Delaunay produces boundary-conforming, quasi-structured mesh
- Better triangle quality for manufacturing (more equilateral)
- Drop-in replacement: same signature as generate_triangulation()
2026-02-17 21:48:55 +00:00
78f56a68b0 fix: boundary conformance — use Shapely buffer + vertex-preserving PSLG sampling
Root cause: typed segment offsetting created self-intersecting geometry at
concave corners (notches). Triangle's PSLG boundary didn't match the plotted
inset contour, allowing vertices 7+ mm outside.

Changes:
- _build_inner_plate: always use Shapely buffer(-w_frame) (robust at concavities)
- _sample_ring: use simplified polygon vertices + interpolated points on long edges
  (preserves tight features without vertex clustering)
- Plot uses same inner_plate from triangulation (no mismatch)
- Post-process: snap any residual outside vertices to boundary
- Result: 0 vertices outside inner plate (was 10, up to 7.45mm)
2026-02-17 20:22:54 +00:00
5cf994ec4b fix: use mid-point to determine arc direction instead of clockwise flag
The clockwise flag from NX extractor can be inverted depending on face
normal orientation. The sampled mid-point is always reliable. Now
_arc_angles checks which direction (CW vs CCW) passes through the
mid-point and uses that.
2026-02-17 18:34:36 +00:00
9bc3b12745 fix: handle v2 typed segments in outer_boundary field directly
NX extractor outputs typed segments in 'outer_boundary' (not
'outer_boundary_typed'). Normalize now detects dict segments and
promotes them correctly.
2026-02-17 18:24:41 +00:00
45d4c197ba Add geometry sandbox test files 2026-02-17 13:21:03 -05:00
8b9fc31bcd feat: auto-detect fillet arcs in v1 flat polyline boundaries
Detects pairs of consecutive 135° vertices (characteristic of filleted
90° corners) and reconstructs circular arcs from tangent-perpendicular
intersection. Verified on sandbox 2: 2 arcs detected at R=7.5mm with
correct centers. Chain continuity validated.

When arcs are detected, v1 boundaries get promoted to v2 typed segments
and the polyline is re-densified with proper arc interpolation.
2026-02-17 18:05:14 +00:00
fbbd3e7277 refactor: rewrite triangulation using Triangle library (constrained Delaunay + quality refinement)
- Replace scipy.spatial.Delaunay with Shewchuk's Triangle (PSLG-based)
- Boundary conforming: PSLG constrains edges along inset contour + hole keepout rings
- Quality: min angle 25°, no slivers
- Per-triangle density-based area refinement (s_min=20, s_max=80)
- Clean boundary plotting (no more crooked v1 line resampling)
- Triangulation plot shows inset contour (red dashed) + keepout rings (orange dashed)
- Add sandbox2_brain_input.json geometry file
2026-02-17 17:14:11 +00:00
1a14f7c420 fix: v1 boundary handling — inset vertices, 3-point hole keepouts, boundary-aligned triangles, smooth plotting
- Triangulation: force inset boundary corner vertices for v1 geometry (Shapely buffer)
- Hole keepouts: 3 evenly-spaced points per circular hole (not dense polyline)
- Boundary layer: seed points derived from inset polygon for proper alignment
- Triangle filtering: full polygon coverage check against inset-valid region
- Plotting: uniform polyline resampling for smooth v1 boundaries, analytic circle rendering
- Verified: 0 bad triangles on both Quicksat sandboxes
2026-02-17 16:24:27 +00:00
139a355ef3 Add v2 geometry normalization and boundary-layer seed points 2026-02-17 14:37:13 +00:00
7d5bd33bb5 brain: add arc-aware inset boundary handling 2026-02-17 14:05:28 +00:00
18a8347765 feat: enforce Delaunay vertices at inset boundary corners + update geometry to v2.0 with arcs
- Add explicit corner vertices of the inset boundary (w_frame offset) to Delaunay point set
- This guarantees no triangle can cross a boundary corner
- Updated test_data geometry files to v2.0 format with typed segments
- Sandbox 2 now has proper arc curves (4 arc segments) from extract_sandbox
- Preserved holes from v1.0 geometry
- Boundary vertices also enforced on keepout boundaries
2026-02-17 13:41:24 +00:00
856ff239d6 fix: match reference rib profile style — green boundary, pink outlines, blue holes, 2mm w_frame, zoomed corner view, pocket clipping to inner plate 2026-02-17 12:56:58 +00:00
732e41ec3a fix: clip pockets and triangulation to boundary in plots — no visual crossovers 2026-02-17 12:42:52 +00:00
39a3420a8e Fix: skip pockets crossing sandbox boundary
profile_assembly.py now checks each pocket's polyline against the plate
boundary using Shapely contains(). Pockets extending outside are dropped.
Sandbox 1: 1 pocket removed (was crossing corner near x=150, y=-20).
2026-02-17 11:41:48 +00:00
03232be7b1 chore(hq): daily sync 2026-02-17 2026-02-17 10:00:15 +00:00
44a5b4aac5 import_profile: use structured pocket outlines (lines+arcs), not rib_web polylines
Reverts to drawing outer boundary + pocket outlines (3 lines + 3 arcs
per pocket) + bolt hole circles. These are the red curves from the
Brain plot. NX sketch regions between outer boundary and pocket/hole
outlines define the rib web material for extrusion.

The rib_web Shapely approach was wrong: it approximated arcs as dense
polylines, losing the clean geometry.
2026-02-17 03:10:32 +00:00
1badc370ab Add rib_web to Brain output + import_profile draws rib material
Brain: profile_assembly.py now exports 'rib_web' — the actual material
geometry from Shapely boolean (exterior + interior rings). This is the
rib shape, not the pocket cutouts.

import_profile.py: prefers rib_web when available, drawing exterior +
interior polyline rings directly. Falls back to pocket-based drawing
for older rib JSONs without rib_web.
2026-02-17 03:02:15 +00:00
0bc0c24c1c import_profile: draw bolt holes from rib profile JSON
Holes drawn as two 3-point arcs (semicircles) using center/diameter.
Both structured and legacy pocket formats supported.
2026-02-17 02:54:49 +00:00
f61616d76a Update test_data rib profiles: sandbox_2 new geometry, re-run Brain for both
- sandbox_1: 75 pockets, 2315g mass estimate
- sandbox_2: 11 pockets (was 10), 371g mass estimate, updated geometry from NX
2026-02-17 02:45:19 +00:00
e07c26c6fe test: save NX-exported v1.0 geometry for sandbox_1 (from Antoine) 2026-02-17 02:39:01 +00:00
68ebee7432 test: Brain profiles from latest geometry with mid-field arcs 2026-02-17 02:36:38 +00:00
dc34b7f6d5 fix: arc midpoint parsing + edge type detection for NX integer enums
- EvaluateUnitVectors returns nested lists — added recursive parser
- SolidEdgeType returns int 1 (not 'Linear') — handle both formats
2026-02-17 02:32:52 +00:00
b6dc15e19e test: Brain-generated rib profiles from existing pipeline
Used existing src/brain/ module (density + Delaunay + pockets).
Sandbox 1: 75 pockets, 16 holes. Sandbox 2: 10 pockets, no holes.
Added v2→v1 geometry converter for Brain compatibility.
2026-02-17 02:26:05 +00:00
b411eaac25 fix: arc direction — sample midpoint from NX edge instead of cross-product
Cross product of (start-center) × (end-center) is zero for 180° arcs,
causing random clockwise assignment. Now samples actual midpoint via
UF Eval at t_mid, stores as 'mid' in JSON. Import prefers 'mid' over
computed clockwise direction.
2026-02-17 02:24:32 +00:00
e3a79d4888 feat: proper alternating up/down isogrid pattern with Shapely clipping
- Alternating up/down equilateral triangles filling full boundary
- buffer(-rib_w) for uniform rib spacing
- buffer(-fillet_r).buffer(+fillet_r) for rounded corners
- Clipped to actual boundary polygon
- Sandbox 2: 39 pockets (40mm), Sandbox 1: 112 pockets (60mm)
2026-02-17 02:17:24 +00:00
6d443df3ec Remap channels: project-dashboard→feed, add #reports channel 2026-02-17 02:08:56 +00:00
d954b2b816 feat: proper isogrid pocket generation with boundary clipping + v2.0 outer boundary
- Equilateral triangle grid pattern
- Shapely polygon clipping to actual boundary shape
- v2.0 typed segments (arcs) for outer boundary
- 4mm fillets, 3mm ribs, 2mm frame offset
- Sandbox 1: 25 pockets (80mm), Sandbox 2: 8 pockets (50mm)
2026-02-17 02:08:01 +00:00
43aea01fb5 test: larger cells (120mm/75mm), 4mm fillets, 2mm frame — 9+2 pockets 2026-02-17 02:00:33 +00:00
709612ece4 test: regenerate rib profiles with 4mm fillets, no frame offset 2026-02-17 01:56:39 +00:00
b38194c4d9 test: add rib profile test JSONs for sandbox_1 (64 pockets) and sandbox_2 (9 pockets) 2026-02-17 01:54:48 +00:00
634bf611c9 fix: remove stale chord_tol_mm kwarg from main() 2026-02-17 01:49:24 +00:00
612a21f561 feat(adaptive-isogrid): preserve arcs as typed segments instead of polyline discretization
Schema v2.0: outer_boundary is now a list of typed segments:
  - {type: 'line', start: [x,y], end: [x,y]}
  - {type: 'arc', start: [x,y], end: [x,y], center: [x,y], radius: R, clockwise: bool}

Extract: detect arcs via UF Eval.IsArc/AskArc, output exact geometry.
Import: create NX sketch arcs (3-point) for arc segments, backward-compatible with v1.0 polylines.
2026-02-17 01:47:36 +00:00
abc7d5f013 fix(extract): increase chord tolerance to 1mm, cap at 500 pts/edge
0.1mm was generating thousands of unnecessary points on straight
edges. Now: 1mm default, 0.5mm minimum, 500 max per edge.
Curves still get proper sampling, straight edges stay lean.
2026-02-17 01:40:55 +00:00
c3125b458b Add taskboard CLI tool for kanban orchestration (Phase 1 of plan 13) 2026-02-17 01:39:33 +00:00
cd7f7e8aa9 fix(extract): use EvaluateUnitVectors for parametric edge sampling
Available NXOpen.UF.Eval methods discovered:
- EvaluateUnitVectors(evaluator, t) - parametric point+tangent
- AskArc(evaluator) - arc center/radius for circular edges
- Initialize2, AskLimits, Free - evaluator lifecycle

Also logs arc data attributes for debugging.
2026-02-17 01:36:25 +00:00
fbdafb9a37 fix(extract): discover UF curve eval methods dynamically
NXOpen Python wraps UF methods with version-specific names.
Now dumps available methods on UF.Modl, UF.Eval, UF.Curve
and tries them in order. Detailed logging shows which method
was found and used, plus raw result format on parse failures.
2026-02-17 01:33:39 +00:00
fc1c1dc142 fix(extract): use UF_MODL_ask_curve_props instead of UF_EVAL
UF_EVAL.Evaluate() doesn't exist in NXOpen Python.
UF_MODL.AskCurveProps(tag, param) uses normalized 0-1
parameter and returns (point, tangent, normal, binormal,
torsion, radius). Works on all edge types.
2026-02-17 01:31:29 +00:00
97fe055b8d Add plan 13: Taskboard/Kanban Dynamic Project Orchestration 2026-02-17 01:27:54 +00:00
89e0ffbbf2 Fix NX curved edge sampling with robust UF_EVAL parsing 2026-02-17 01:24:55 +00:00
20d035205a fix(extrude): start extend negative for symmetric extrude 2026-02-17 01:16:36 +00:00
e6f98ac921 feat(extrude): symmetric extrude using part expression
- Uses ISOGRID_RIB_sandbox_N_thk expression for thickness
- Creates expression if missing, uses existing if present
- Symmetric extrude: ±thk/2 from sketch plane
- Fallback to literal value if expression fails
2026-02-17 01:12:16 +00:00
9a5f086684 fix(extrude): robust section creation with multi-approach fallback
- Create section explicitly if builder.Section returns None
- Try 3 approaches for adding curves: CreateRuleCurveFeature,
  CreateRuleCurveDumb with options, CreateRuleCurveDumb without
- Detailed step-by-step logging for debugging
- Get help point from first sketch curve for section seeding
2026-02-17 00:59:37 +00:00
070a211c69 fix(nxopen): simplify sketch extrude and correct rule/builder APIs 2026-02-17 00:55:36 +00:00
4c3457c17c fix: add missing NXOpen.Features and NXOpen.GeometricUtilities imports
NXOpen submodules must be imported explicitly in NX's Python
environment - they're not accessible as attributes of the
parent module.
2026-02-17 00:46:50 +00:00
ecba40f189 feat(import_profile): auto-extrude after sketch creation
Full cycle now automated:
1. Delete old extrude (if exists)
2. Delete old sketch (try ReplaceFeatureBuilder first, fallback to delete)
3. Create new sketch with rib geometry
4. Extrude new sketch by rib thickness along face normal
5. Name both features for identification on next iteration

Rib thickness read from profile JSON (parameters_used.thickness)
with fallback to geometry JSON or default 10mm.

No more manual extrude step needed between iterations.
2026-02-17 00:40:00 +00:00
515eef145f feat(import_profile): use ReplaceFeatureBuilder for sketch replacement
Replaces naive ReplaceEntity approach with NX's proper
ReplaceFeatureBuilder API. This mirrors the interactive
right-click → Replace Feature workflow:

- SelectFeature: old sketch to replace
- ReplacementFeature: new sketch taking over
- DoAutomaticGeomMatch: auto-maps curves
- DeleteOriginalFeature: removes old after remap
- Fallback: manual delete if builder fails

All downstream features (extrude, trim) automatically
re-reference the new sketch.
2026-02-16 23:36:14 +00:00
c4d98ee97c Importer: rename sketch feature + replace/delete old sketch on update 2026-02-16 22:00:18 +00:00
1bfc747cf9 Fix importer: always create new sketch + generate sandbox 2 rib profile (11 pockets) 2026-02-16 21:49:07 +00:00
c5226084fe Generate sandbox 2 rib profile (11 pockets, validated) 2026-02-16 21:33:58 +00:00
98e4b2be02 Add sandbox 2 rib profile geometry 2026-02-16 21:27:27 +00:00
379801c8aa demo: cantilever scenario ready for NX test (52 pockets, 343 entities)
rib_profile_sandbox_1.json now contains the cantilever demo profile.
Also includes 4 demo scenarios with density heatmaps + profiles.
2026-02-16 21:15:09 +00:00
1021f57abc fix(pockets): skip pocketing in high-density zones (eta > eta_solid)
High density (η > 0.7) means high stress → leave solid, no pocket.
Only low-density regions get lightweighted.

Scenario comparison with s_min=30, s_max=70:
- Baseline uniform (w=0.5): 78 pockets, 2886g
- Bottom-right heavy: 41 pockets, 3516g (bottom stays solid)
- Left-side mount: 27 pockets, 3799g (left stays solid)
- Center pressure: 15 pockets, 4295g (center stays solid)
2026-02-16 21:05:56 +00:00
4f051aa7e1 refactor(triangulation): hex grid isogrid layout replaces constrained Delaunay
Complete rewrite of triangulation engine:
- Regular hexagonal-packed vertex grid (equilateral triangles)
- Density-adaptive refinement: denser near holes, coarser in open areas
- Boundary-conforming vertices along frame edge and hole keepouts
- Delaunay on point set + clip to valid region (inside frame, outside keepouts)
- Result: proper isogrid layout, 87 pockets from 234 triangles
- 553 NX entities, min fillet 4.89mm, mass 2770g
- No more dependency on Shewchuk Triangle library (scipy.spatial.Delaunay)
2026-02-16 20:58:05 +00:00
239e2f01a9 tune(brain): uniform triangulation + bigger spacing for r_f=6mm fillets
- Switched to uniform triangulation (no density-adaptive refinement for
  initial pass — saves that for stress-informed iterations)
- s_min=45mm, s_max=55mm (was 12/35) — larger triangles fit 6mm fillets
- Boss keepout circles: 12 segments (was 32) — less boundary clutter
- Fillet must be >= 80% of r_f at every corner or pocket is skipped
- Result: 75 pockets, 481 NX entities, min fillet 4.85mm, mass 4066g
- adaptive_density=True param enables density refinement for future stress iterations
2026-02-16 20:51:20 +00:00
30981fa066 fix(brain): enforce r_f=6mm minimum, reject pockets that can't fit fillets
- Default r_f raised from 1.5mm to 6mm (machining constraint)
- Default min_pocket_radius raised to 6mm
- Pockets that can't fit r_f at any corner (within 80% tolerance) are
  skipped entirely — left solid for more stiffness in tight areas
- Result: 26 pockets (was 432), 187 NX entities (was 13,061)
- Min fillet radius: 4.88mm, all >= 4.8mm (80% of 6mm)
- Mass: 4,601g (was 3,480g — more solid = heavier but manufacturable)
2026-02-16 20:42:46 +00:00
da9b579bcf refactor(brain): structured pocket output — 3 lines + 3 arcs per pocket
Replaced Shapely buffer-based fillet (59-pt polylines) with exact geometric
fillet computation. Each pocket now outputs:
- 3 straight edges (line start/end pairs)
- 3 fillet arcs (center, radius, tangent points, angles)

NX import updated to use SketchLineBuilder + SketchArcBuilder (3-point).
Total NX entities: ~2,600 (was ~13,000). Includes arc fallback to 2-line
segments if SketchArcBuilder fails.

Also outputs circular hole definitions for future NX circle creation.
2026-02-16 20:17:49 +00:00
fdcafe96a9 fix(import): use SketchLineBuilder instead of model curves + AddGeometry
Model curves (part.Curves.CreateLine) are SmartObjects that can't be added
to a sketch via AddGeometry. Switch to SketchLineBuilder which creates
native sketch geometry directly (SetStartPoint/SetEndPoint/Commit).
2026-02-16 20:02:11 +00:00
fbdbf6b362 fix(import): use Sketch.InferConstraintsOption enum (nested under NXOpen.Sketch, not NXOpen) 2026-02-16 19:56:31 +00:00
4e0c9cd24d fix: correct enum names from MCP - InferNoConstraints, TreatAsEllipse, UpdateLevel.Model runtime resolve 2026-02-16 19:50:24 +00:00
c93239c9c6 fix: strip closing duplicate points in triangulation (segfault fix), batch line creation for NX speed, 6mm endmill params 2026-02-16 19:29:41 +00:00
61dcefb5ea fix: resolve ViewReorient/UpdateLevel enum at runtime with multiple fallback paths 2026-02-16 19:15:57 +00:00
8143da96e9 fix: correct enum names - ViewReorient.FalseValue, UpdateLevel.Model (verified from MCP) 2026-02-16 19:14:22 +00:00
9534ba9ed9 fix: Builder.Commit() not CommitFeature(), correct AddGeometry signature, verbose commit logging 2026-02-16 19:11:28 +00:00
4fc129e35b fix: try setattr/SetX/method patterns for SketchInPlaceBuilder properties (NXOpen Python getter/setter naming collision) 2026-02-16 19:08:06 +00:00
bf1f461e2b fix: use Plane (SmartObject) not DatumPlane, method calls not property setters (verified from MCP stubs) 2026-02-16 19:05:12 +00:00
7a2c002672 fix: use Matrix3x3 for datum plane, property assignment for SketchInPlaceBuilder2 2026-02-16 18:57:31 +00:00
bf4e84d45a fix: use Planes.CreatePlane + Points.CreatePoint + Directions.CreateDirection for sketch creation 2026-02-16 18:54:21 +00:00
ef8801a5cd test: add sandbox1 rib profile output for import testing 2026-02-16 18:49:21 +00:00
f4cfc9b1b7 feat(adaptive-isogrid): import_profile.py - push rib profile as NX sketch, sandbox1 brain input test file 2026-02-16 18:45:24 +00:00
23b6fe855b fix: handle closed circular edges (holes) - UF.Eval + GetLength circle fallback + debug logging 2026-02-16 17:57:06 +00:00
98d510154d fix: rewrite edge sampling + loop building using verified NXOpen API (GetVertices, GetEdges, GetLength, UF.Eval) 2026-02-16 17:46:52 +00:00
851a8d3df0 fix: replace face.GetLoops() with compatible API (GetEdgeLoops / UF layer / GetEdges fallback) 2026-02-16 17:42:08 +00:00
1166741ffd fix: add try/except + debug logging around sandbox extraction 2026-02-16 17:31:33 +00:00
afaa925da8 fix: search features + feature names for ISOGRID_SANDBOX attribute (Promote Body stores attrs on feature, not body) 2026-02-16 17:26:31 +00:00
6251787ca5 merge: take remote extract_sandbox.py v2 2026-02-16 12:22:56 -05:00
40213578ad merge: recover Gitea state - HQ docs, cluster setup, isogrid work
Merge recovery/gitea-before-force-push to restore:
- hq/ directory (cluster setup, docker-compose, configs)
- docs/hq/ (12+ HQ planning docs)
- docs/guides/ (documentation boundaries, PKM standard)
- docs/plans/ (model introspection master plan)
- Isogrid extraction work
- Hydrotech-beam: keep local DOE results, remove Syncthing conflicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:22:33 -05:00
26100a9624 feat(adaptive-isogrid): extract_sandbox.py v2 - NX journal compatible, no argparse, sim→idealized navigation, listing window output 2026-02-16 17:20:28 +00:00
ed6874092f chore: clean hydrotech-beam syncthing conflicts and add new docs
- Remove all .sync-conflict-* files
- Remove temp _temp_part_properties.json files
- Add USER_GUIDE.md
- Add dashboard docs (Executive, Technical, Operations, Master Plan)
- Add playbooks (DOE, NX_REAL_RUN, SYNCTHING_RECOVERY)
- Update iteration results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:11:16 -05:00
bb83bb9cab feat(adaptive-isogrid): rewrite extract_sandbox.py - start from .sim, navigate to idealized part, find sandbox solid bodies by ISOGRID_SANDBOX attribute, inner loops as boundary constraints 2026-02-16 17:07:26 +00:00
fa9193b809 chore(hq): daily sync 2026-02-16 2026-02-16 10:00:29 +00:00
3184eb0d0e Add doc 12: Context lifecycle management — condensation, threads, staleness 2026-02-16 02:26:19 +00:00
85d40898f0 Revise spec to reserved-region FEM and add Phase 2 NX sandbox scripts 2026-02-16 02:04:19 +00:00
7086f9fbdf Add doc 11: HQ improvements plan from Bhanu video analysis 2026-02-16 01:19:27 +00:00
e4651c9a40 Adaptive isogrid: min triangle area filtering and circular hole bosses 2026-02-16 01:11:53 +00:00
9d4c37234a Add standalone brain CLI, test geometries, and robustness sweep outputs 2026-02-16 00:12:12 +00:00
4bec4063a5 feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
  pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
  per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
cf82de4f06 docs: add HQ multi-agent framework documentation from PKM
- Project plan, agent roster, architecture, roadmap
- Decision log, full system plan, Discord setup/migration guides
- System implementation status (as-built)
- Cluster pivot history
- Orchestration engine plan (Phases 1-4)
- Webster and Auditor reviews
2026-02-15 21:44:07 +00:00
3289a76e19 feat: add Atomizer HQ multi-agent cluster infrastructure
- 8-agent OpenClaw cluster (Manager, Tech-Lead, Secretary, Auditor,
  Optimizer, Study-Builder, NX-Expert, Webster)
- Orchestration engine: orchestrate.py (sync delegation + handoffs)
- Workflow engine: YAML-defined multi-step pipelines
- Agent workspaces: SOUL.md, AGENTS.md, MEMORY.md per agent
- Shared skills: delegate, orchestrate, atomizer-protocols
- Capability registry (AGENTS_REGISTRY.json)
- Cluster management: cluster.sh, systemd template
- All secrets replaced with env var references
2026-02-15 21:18:18 +00:00
d6a1d6eee1 auto: daily sync 2026-02-15 08:00:21 +00:00
6218355dbf auto: daily sync 2026-02-14 08:00:22 +00:00
0795cccc97 auto: daily sync 2026-02-13 08:00:19 +00:00
580ed65a26 fix: generic mass extraction in solve_simulation.py (beam + bracket)
- Extract mass RIGHT AFTER geometry rebuild while part is work part
- Replace unreliable p173 expression lookup with MeasureManager
- Skip re-extraction if mass already captured during rebuild
- Relax displacement constraint to 20mm (DEC-HB-012, CEO approved)

Root cause: journal hardcoded M1_Blank for bracket, failed silently on Beam.prt
Fix by: NX Expert + Manager diagnosis
2026-02-13 02:16:39 +00:00
57130ccfbc docs: add nightly memory digestion methodology 2026-02-12 14:20:57 +00:00
6f3325d86f fix: mass extraction NaN in Hydrotech Beam DOE — two bugs
Bug 1 — Journal (solve_simulation.py simple workflow):
  Expression lookup for p173 fails silently for derived/measurement
  expressions, so _temp_mass.txt was never written. Added MeasureManager
  fallback via extract_part_mass() (already used in assembly workflow).

Bug 2 — Extractor (extract_mass_from_expression.py):
  Journal writes 'p173=<value>' format but extractor tried float() on
  the whole content including 'p173='. Added key=value parsing.

Defense in depth — nx_interface.py:
  Added stdout parsing fallback: if _temp_mass.txt still missing, parse
  mass from journal output captured via solver.py stdout passthrough.

Files changed:
  - optimization_engine/nx/solve_simulation.py — MeasureManager fallback
  - optimization_engine/extractors/extract_mass_from_expression.py — key=value parse
  - optimization_engine/nx/solver.py — include stdout in result dict
  - projects/hydrotech-beam/studies/01_doe_landscape/nx_interface.py — stdout fallback

Tags: hydrotech-beam, mass-extraction
2026-02-11 19:02:43 +00:00
04f06766a0 docs: Atomizer HQ Dashboard — full plan (CEO-requested)
Five-pane architecture:
- Project Blueprint (CONTEXT.md → live view)
- Study Tracker (enhanced real-time monitoring)
- Command Center (remote NX execution from browser)
- Agent Console (interact with HQ agents)
- Reports & Export (PDF/HTML generation)

Phased implementation: D1-D5 (7-12 weeks total, MVP at D3)
Extends existing atomizer-dashboard (no rewrite)
Progressive: file-based bridge → WebSocket → NX MCP
2026-02-11 18:32:54 +00:00
b419510b1a feat: add Hydrotech Beam DOE landscape results (39 iterations)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:25:21 -05:00
2fde08daab docs: update KB and project docs with 2026-02-11 progress
- KB Gen 003: NX version (DesigncenterNX 2512), first real results
- sol101-static.md: path resolution lessons, in-place solving, result extraction confirmed
- CONTEXT.md: solver pipeline operational, first results (disp=17.93mm, stress=111.9MPa)
- DECISIONS.md: DEC-HB-008 to DEC-HB-011 (backup/restore, iteration arch, history DB, git workflow)
- optimization_engine/README.md: created (DesigncenterNX support, path resolution, NX file refs)
- studies/01_doe_landscape/README.md: updated architecture, iteration folders, history DB
- _index.md: closed gaps G3,G4,G6,G10-G14, updated generation to 003
2026-02-11 16:39:21 +00:00
93a5508c07 Fix mass extraction + db close order + nan handling
- Journal now extracts p173 mass expression and writes _temp_mass.txt
- history.get_study_summary() called before history.close()
- Optuna nan rejection: fallback to INFEASIBLE_MASS penalty
- pyNastran warning 'nx 2512 not supported' is harmless (reads fine)
2026-02-11 16:29:45 +00:00
0229ce53bb Fix NX version: DesigncenterNX2512 (was looking for NX2412)
- Add DesigncenterNX{version} to install path search
- Update default version to 2512
- Root cause of 'Part file is from a newer version' error
2026-02-11 15:54:32 +00:00
80104d2467 FIX: Resolve all paths to absolute before passing to NX
Root cause: Path.absolute() on Windows does NOT resolve '..' components.
sim_file_path was reaching NX as '...\studies\01_doe_landscape\..\..\models\Beam_sim1.sim'
NX likely can't resolve referenced parts from a path with '..' in it.

Fixes:
- nx_interface.py: glob from self.model_dir (resolved) not model_dir (raw)
- solver.py: sim_file.resolve() instead of sim_file.absolute()
- solve_simulation.py: os.path.abspath(sim_file_path) at entry point
- Diagnostic logging still in place for next run
2026-02-11 15:24:20 +00:00
55f0f917c7 Add NX diagnostic logging: OpenActiveDisplay result, load status, Parts.Open details
Need to see why Parts.Open returns None even from the master model folder.
Logs: basePart1 type/name/path, unloaded parts status, file existence checks.
2026-02-11 15:16:26 +00:00
3718a8d5c8 Fix NX solve: backup/restore master model, archive outputs to iterations
NX .sim files store absolute internal references to .fem/.prt files.
Copying them to iteration folders breaks these references (Parts.Open
returns None). Instead:

1. Backup master model once at study start
2. Restore from backup before each trial (isolation)
3. Solve on master model in-place (NX references intact)
4. Archive solver outputs (OP2/F06) + params.exp to iterations/iterNNN/
5. params.exp in each iteration: import into NX to recreate any trial

iteration_manager.py kept for future use but not wired in.
2026-02-11 15:05:18 +00:00
815db0fb8d Add persistent trial history DB (append-only, survives --clean)
- history.db: SQLite append-only, never deleted by --clean
- history.csv: Auto-exported after each trial (live updates)
- Logs: DVs, results, feasibility, status, solve time, iter path
- Cross-study queries: full lineage across all runs/phases
- --clean only resets Optuna DB, history preserved
2026-02-11 14:59:52 +00:00
04fdae26ab Smart iteration management: full model copies + retention policy
- Each iteration gets full model files in iterations/iterNNN/ (openable in NX)
- Retention: keep last 10 + best 3 with full models, strip the rest
- Stripped iterations keep solver outputs (OP2, F06, params, results)
- All paths resolved to absolute before passing to NX (fixes reference issue)
- iteration_manager.py: reusable for future studies
2026-02-11 14:48:05 +00:00
e8877429f8 Smart isolation: backup/restore master model before each trial
- One-time backup of model files at study start (_model_backup/)
- Restore clean state before each trial (files stay in models/, NX refs intact)
- If a trial corrupts the model, next trial starts clean
- Best of both: NX reference integrity + trial isolation
2026-02-11 14:42:07 +00:00
4243a332a3 Iteration archival: solve on master model, archive outputs to studies/iterations/iterNNN/
- Each iteration gets: params.json, results.json, OP2, F06, mass files
- Model directory stays clean (no solver output buildup)
- Study folder is self-contained with full trial history
2026-02-11 14:39:10 +00:00
60dbf5b172 Disable iteration folders: copied NX files break internal references, solve directly on master model 2026-02-11 14:35:56 +00:00
686ec2ac6c KB: document simple vs assembly FEM workflow, automation notes 2026-02-11 14:26:59 +00:00
0e459028fe Fix: FEM part lookup (exclude _i.prt), hole_count unit (Constant not mm), add file logging
- solve_simulation.py: FEM finder now excludes idealized parts, falls back to loading .fem
- solve_simulation.py: hole_count written as [Constant] not [MilliMeter] in .exp
- run_doe.py: dual logging to console + results/doe_run.log
2026-02-11 14:17:43 +00:00
126f0bb2e0 Refactor: nx_interface uses optimization_engine (NXSolver + pyNastran extractors)
- AtomizerNXSolver wraps existing NXSolver + extractors from SAT3 pipeline
- HEEDS-style iteration folders with fresh model copies per trial
- Expression .exp file generation with correct unit mapping
- pyNastran OP2 extraction: displacement, von Mises (kPa→MPa), mass
- StubSolver improved with beam-theory approximations
- Reuses proven journal pipeline (solve_simulation.py)
2026-02-11 13:33:09 +00:00
135698d96a Fix: SQLite duplicate study (load_if_exists), sampling crash with n<11, add --clean flag 2026-02-11 13:09:30 +00:00
e8b4d37667 auto: daily sync 2026-02-11 08:00:20 +00:00
390ffed450 feat(hydrotech-beam): complete NXOpenSolver.evaluate() implementation
Complete the NXOpenSolver class in nx_interface.py with production-ready
evaluate() and close() methods, following proven patterns from
M1_Mirror/SAT3_Trajectory_V7.

Pipeline per trial:
1. NXSolver.create_iteration_folder() — HEEDS-style isolation with fresh
   model copies + params.exp generation
2. NXSolver.run_simulation() — journal-based solve via run_journal.exe
   (handles expression import, geometry rebuild, FEM update, SOL 101)
3. extract_displacement() — max displacement from OP2
4. extract_solid_stress() — max von Mises with auto-detect element type
   (tries all solid types first, falls back to CQUAD4 shell)
5. extract_mass_from_expression() — reads _temp_mass.txt from journal,
   with _temp_part_properties.json fallback

Key decisions:
- Auto-detect element type for stress (element_type=None) instead of
  hardcoding CQUAD4 — the beam model may use solid or shell elements
- Lazy solver init on first evaluate() call for clean error handling
- OP2 fallback path: tries solver result first, then expected naming
  convention (beam_sim1-solution_1.op2)
- Mass fallback: _temp_mass.txt -> _temp_part_properties.json
- LAC-compliant close(): only uses session_manager.cleanup_stale_locks(),
  never kills NX processes directly

Expression mapping (confirmed from binary introspection):
- beam_half_core_thickness, beam_face_thickness, holes_diameter, hole_count
- Mass output: p173 (body_property147.mass, kg)

Refs: OP_09, OPTIMIZATION_STRATEGY.md §8.2
2026-02-11 01:11:09 +00:00
33180d66c9 Rewrite NXOpenSolver to use existing Atomizer optimization engine
- Use optimization_engine.nx.updater.NXParameterUpdater for expression updates (.exp import method)
- Use optimization_engine.nx.solver.NXSolver for journal-based solving (run_journal.exe)
- Use optimization_engine.extractors for displacement, stress, and mass extraction
- Displacement: extract_displacement() from pyNastran OP2
- Stress: extract_solid_stress() with cquad4 support (shell elements), kPa→MPa conversion
- Mass: extract_mass_from_expression() reads from temp file written by solve journal
- Support for iteration folders (HEEDS-style clean state between trials)
- Proper error handling with TrialResult(success=False, error_message=...)
- 600s timeout per trial (matching existing NXSolver default)
- Keep stub solver and create_solver() factory working
- Maintain run_doe.py interface compatibility
2026-02-10 23:26:51 +00:00
017b90f11e feat(hydrotech-beam): Phase 1 LHS DoE study code
Implements the optimization study code for Phase 1 (LHS DoE) of the
Hydrotech Beam structural optimization.

Files added:
- run_doe.py: Main entry point — Optuna study with SQLite persistence,
  Deb's feasibility rules, CSV/JSON export, Phase 1→2 gate check
- sampling.py: 50-point LHS via scipy.stats.qmc with stratified integer
  sampling ensuring all 11 hole_count levels (5-15) are covered
- geometric_checks.py: Pre-flight feasibility filter — hole overlap
  (corrected formula: span/(n-1) - d ≥ 30mm) and web clearance checks
- nx_interface.py: NX automation module with stub solver for development
  and NXOpen template for Windows/dalidou integration
- requirements.txt: optuna, scipy, numpy, pandas

Key design decisions:
- Baseline enqueued as Trial 0 (LAC lesson)
- All 4 DV expression names from binary introspection (exact spelling)
- Pre-flight geometric filter saves compute and prevents NX crashes
- No surrogates (LAC lesson: direct FEA via TPE beats surrogate+L-BFGS)
- SQLite persistence enables resume after interruption

Tested end-to-end with stub solver: 51 trials, 12 geometric rejects,
39 solved, correct CSV/JSON output.

Ref: OPTIMIZATION_STRATEGY.md, auditor review 2026-02-10
2026-02-10 22:15:06 +00:00
94bff37a67 Fix spacing formula (span/(n-1)), web height constraint, resolve audit blockers 2026-02-10 22:07:39 +00:00
3e5180485c Update optimization strategy with introspection-corrected baselines 2026-02-10 22:02:46 +00:00
15a457d2be KB introspection: corrected mass 1133 kg, DV baselines, full expression map from Beam.prt binary 2026-02-10 21:57:21 +00:00
b88657b00c KB Gen 002: Process KBS sessions, update model parameters
Sources: 3 KBS capture sessions (20260210-132817, 20260210-161401, 20260210-163801)

Key changes:
- Mass corrected: 974 kg (p173) → 11.33 kg (p1) — KBS ground truth
- Beam length confirmed: 5,000 mm cantilever
- BCs confirmed: left fixed, right 10,000 kgf downward
- Material confirmed: AISI Steel 1005, density 7.3 g/cm³
- Mesh confirmed: CQUAD4 thin shell, 33.7 mm elements
- Hole geometry: span 4,000 mm (p6), offsets 500 mm fixed
- 3 gaps closed (G1, G2, G8), 6 new gaps identified (G10-G15)
- New expressions: beam_half_height, beam_half_width, beam_length, p6

Files: CONTEXT.md, kb/_index.md, kb/_history.md, kb/components/sandwich-beam.md,
       kb/materials/steel-aisi.md, kb/fea/models/sol101-static.md, kb/dev/gen-002.md
2026-02-10 21:49:39 +00:00
3ab1cad4e1 auto: daily sync 2026-02-10 08:00:22 +00:00
857c01e7ca chore: major repo cleanup - remove dead code and cruft
Remove ~24K lines of dead code for a lean rebuild foundation:

- Remove atomizer-field/ (neural field predictor experiment, concept archived in docs)
- Remove generated_extractors/, generated_hooks/ (legacy generator outputs)
- Remove optimization_validation/ (empty skeleton)
- Remove reports/ (superseded by optimization_engine/reporting/)
- Remove root-level stale files: DEVELOPMENT.md, INSTALL_INSTRUCTIONS.md,
  config.py, atomizer_paths.py, optimization_config.json, train_neural.bat,
  generate_training_data.py, run_training_fea.py, migrate_imports.py
- Update .gitignore for introspection caches and insight outputs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 14:26:37 -05:00
8d9d55356c docs: Archive stale docs and create Atomizer-HQ agent documentation
Archive Management:
- Moved RALPH_LOOP, CANVAS, and dashboard implementation plans to archive/review/ for CEO review
- Moved completed restructuring plan and protocol v1 to archive/historical/
- Moved old session summaries to archive/review/

New HQ Documentation (docs/hq/):
- README.md: Overview of Atomizer-HQ multi-agent optimization team
- PROJECT_STRUCTURE.md: Standard KB-integrated project layout with Hydrotech reference
- KB_CONVENTIONS.md: Knowledge Base accumulation principles with generation tracking
- AGENT_WORKFLOWS.md: Project lifecycle phases and agent handoffs (OP_09 integration)
- STUDY_CONVENTIONS.md: Technical study execution standards and atomizer_spec.json format

Index Update:
- Reorganized docs/00_INDEX.md with HQ docs prominent
- Updated structure to reflect new agent-focused organization
- Maintained core documentation access for engineers

No files deleted, only moved to appropriate archive locations.
2026-02-09 02:48:35 +00:00
9541958eae Restructure Hydrotech Beam project — KB-integrated layout
New project structure with knowledge base integration:
- kb/ with components, materials, fea, dev generations
- models/ for reference NX files (golden copies)
- studies/ for self-contained optimization campaigns
- deliverables/ for final outputs
- DECISIONS.md decision log (6 decisions tracked)
- BREAKDOWN.md (moved from 1_breakdown/)
- Gen 001 created from intake + technical breakdown

KB extension file: atomizer/shared/skills/knowledge-base-atomizer-ext.md

Refs: DEC-HB-004, DEC-HB-005, DEC-HB-006
2026-02-09 02:18:29 +00:00
ca4101dcb0 feat: improve optical report with embedded Plotly and 4x PNG export
- Embed Plotly.js inline for offline viewing (fixes CDN loading issues)
- Add 4x resolution PNG export for all charts via toImageButtonOptions
- Add SAT3_Trajectory_V7 study (TPE warm-start from V5, 86 trials, WS=277.37)
- Include V7 optimization report and configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 19:29:34 -05:00
65711cdbf1 Add Hydrotech Beam project files - CONTEXT.md and TECHNICAL_BREAKDOWN.md
Project intake and technical breakdown for beam structural optimization.
4 design variables, SOL 101, single-objective (minimize mass).
2026-02-09 00:23:01 +00:00
a5059dd64a Add PSD figure generation script for CDR reports
Generates publication-ready PSD figures from OP2 FEA data:
- psd_multi_angle.png: PSD curves for all elevation angles
- psd_band_decomposition.png: LSF/MSF/HSF bar chart
- psd_40deg_composite.png: WFE surface + PSD side-by-side
- psd_trajectory.png: Band evolution vs elevation

Uses Atomizer's full OPD pipeline for consistency with CDR WFE numbers.
2026-02-06 17:38:48 +00:00
38d0994d29 Add WFE PSD analysis tools (Tony Hull methodology)
- tools/wfe_psd.py: Quick PSD computation for WFE surfaces
- optimization_engine/insights/wfe_psd.py: Full PSD module with band
  decomposition (LSF/MSF/HSF), radial averaging, Hann windowing,
  and visualization support
- knowledge_base/lac/session_insights/stopp_command_20260129.jsonl:
  Session insight from stopp command implementation

PSD analysis decomposes WFE into spatial frequency bands per Tony Hull's
JWST methodology. Used for CDR V7 to validate that MSF (support
print-through) dominates the residual WFE at 85-89% of total RMS.
2026-02-06 17:38:34 +00:00
5f5d55d107 fix(report): trajectory plots full-width instead of side-by-side
- Remove two-col grid wrapper from trajectory section
- Each plot now gets full container width
- Trajectory plot height 450→500, width 1100 for better readability
2026-01-30 00:06:10 +00:00
27d9dbee5b fix(psd): auto-scale x-axis to data range, improve plot layout
- X-axis now auto-ranges from data (was going to 10^21)
- Band annotations clamped to actual data extent
- Legend moved to upper-right (was overlapping data)
- Thicker lines (2.5px), larger axis labels
- dtick=1 for clean log-scale tick marks
2026-01-30 00:03:38 +00:00
12afd0c54f fix(psd): add angle labels to PSD band decomposition cards 2026-01-29 23:58:14 +00:00
a1000052cb fix(psd): correct normalization using Parseval band summation
- Band RMS now uses direct Parseval: sqrt(sum(|FFT|²) / N⁴ / hann_power)
- Previous approach had dimensional mismatch (cycles/apt vs cycles/mm)
- Results now match Zernike filtered RMS within ~10%:
  40° vs 20°: PSD=6.18nm vs Zernike=7.70nm
  60° vs 20°: PSD=15.83nm vs Zernike=17.69nm
  90° Abs: PSD=27.01nm vs Zernike=22.33nm
- PSD plot curve uses separate normalization (shape, not absolute)
- Refactored compute_surface_psd to return dict with freqs, psd, bands
2026-01-29 23:49:03 +00:00
eeacfbe41a feat(report): replace LSF/MSF with Tony Hull PSD analysis
- Remove compute_spatial_freq_metrics() and _spatial_freq_html()
- Add compute_surface_psd(): 2D FFT + Hann window + radial averaging
- Add compute_psd_band_rms(): gravity/support/HF band decomposition
- Add make_psd_plot(): interactive log-log PSD plot with band annotations
- Add _psd_summary_html(): band RMS cards with % breakdown
- New section in report: Power Spectral Density Analysis
- Zernike details now show only coefficient bar charts (cleaner)
- Methodology: Tony Hull JWST approach for WFE spatial frequency analysis
2026-01-29 22:15:42 +00:00
487ecf67dc feat(report): wider surface maps + spatial frequency band metrics
- CSS .plot-grid: minmax(650px) → minmax(1100px) for full-width surface maps
- Add compute_spatial_freq_metrics(): LSF/MSF band RMS, per-radial-order breakdown
- Add styled metrics cards in Zernike Coefficient Details (section 6)
  showing LSF (J4-J15), MSF (J16-J50), ratio, and per-order RMS n=2..9
2026-01-29 20:46:58 +00:00
faab234d05 fix: update Plotly.js CDN to 3.3.1 (match Python lib 6.5.2 bdata format), show 50 modes 2026-01-29 20:32:55 +00:00
c6427f3c6e fix: replace deprecated titlefont with title.font for Plotly compat 2026-01-29 20:20:01 +00:00
34b52f9543 Add comprehensive optical performance report generator
New tool: tools/generate_optical_report.py
- CDR-ready single HTML report from OP2 results
- Executive summary with pass/fail vs targets
- Per-angle WFE analysis with 3D surface plots
- Zernike trajectory analysis (mode-specific RMS)
- Axial vs lateral sensitivity matrix
- Manufacturing correction metrics
- Collapsible Zernike coefficient bar charts
- Optional study DB integration for design params
- Annular aperture support (default M1 inner R=135.75mm)
- Dark theme, interactive Plotly charts, print-friendly
2026-01-29 18:28:10 +00:00
7df18324b1 feat(extractors): add annular aperture support to trajectory extractor 2026-01-29 17:39:56 +00:00
abdbe9a708 fix: correct all baseline values from actual SAT3 model expression export
Previous baselines were from old V15 study, not from M1_Tensor best design.
Updated all 9 design variables with correct values from model introspection.

Baseline Corrections (from expression export):
- lateral_inner_angle: 26.79° → 30.18° (at upper bound)
- lateral_outer_angle: 14.64° → 15.09°
- lateral_outer_pivot: 5.5mm → 6.036mm (0.4 × 15.09°)
- lateral_inner_pivot: 10.07mm → 12.072mm (0.4 × 30.18°)
- lateral_middle_pivot: 20.73mm → 14.0mm (lower than expected)
- lateral_closeness: 11.02mm → 7.89mm
- whiffle_min: 40.55mm → 56.7mm
- inner_circular_rib_dia: 534.00mm → 537.86mm (fixed parameter)

Bound Adjustments:
- lateral_inner_pivot max: 11.0 → 13.0mm (to accommodate baseline 12.072)
- lateral_closeness min: 9.5 → 5.0mm (to accommodate baseline 7.89)

Root Cause:
- NX introspection failed (NX not running)
- Config was created with V15 study baselines as placeholders
- Actual model values now applied from user-provided expression export

Files Updated:
- optimization_config.json: All baselines corrected
- README.md: Design variable table updated
- STUDY_REPORT.md: Baseline values corrected

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 12:23:43 -05:00
b62605a736 refactor: update SAT3_Trajectory to 9 design variables with refined bounds
Updated configuration based on user adjustments:
- Reduced from 11 to 9 design variables (disabled blank_backface_angle and inner_circular_rib_dia)
- Refined parameter bounds for lateral supports

Design Variable Changes:
- lateral_inner_angle: min 20.0° (was 25.0°)
- lateral_outer_pivot: range 4.0-9.0mm, baseline 5.5mm (was 9.0-12.0mm, baseline 10.40mm)
- lateral_middle_pivot: range 12.0-25.0mm (was 15.0-27.0mm)
- blank_backface_angle: disabled (fixed at 4.00°)
- inner_circular_rib_dia: disabled (fixed at 534.00mm)

Documentation Updated:
- README.md: Updated design variable table with correct ranges and baselines
- STUDY_REPORT.md: Updated to reflect 9 enabled variables
- optimization_config.json: User-modified bounds applied

Rationale:
- Focus optimization on lateral supports and whiffle tree
- Fix geometry parameters to reduce search space
- Tighter bounds on critical lateral parameters

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 12:20:41 -05:00
f80b5d64a8 feat: create SAT3_Trajectory study with Zernike Trajectory Method
First production implementation of trajectory-based optimization for M1 mirror.

Study Configuration:
- Optimizer: TPE (100 trials, 15 startup)
- Primary objective: total_filtered_rms_nm (integrated RMS across 20-60 deg)
- Logged objectives: coma_rms_nm, astigmatism_rms_nm, trefoil_rms_nm, spherical_rms_nm
- Design variables: 11 (full wiffle tree + lateral supports)
- Physics validation: R² fit quality monitoring

Key Features:
- Mode-specific aberration tracking (coma, astigmatism, trefoil, spherical)
- Physics-based trajectory model: c_j(θ) = a_j·sin(θ) + b_j·cos(θ)
- Sensitivity analysis: axial vs lateral load contributions
- OPD correction with focal_length=22000mm
- Annular aperture (inner_radius=135.75mm)

Validation Results:
- Tested on existing M1_Tensor OP2: R²=1.0000 (perfect fit)
- Baseline total RMS: 4.30 nm
- All 5 angles auto-detected: [20, 30, 40, 50, 60] deg
- Dominant mode: spherical (10.51 nm)

Files Created:
- studies/M1_Mirror/SAT3_Trajectory/README.md (complete documentation)
- studies/M1_Mirror/SAT3_Trajectory/STUDY_REPORT.md (results template)
- studies/M1_Mirror/SAT3_Trajectory/run_optimization.py (TPE + trajectory extraction)
- studies/M1_Mirror/SAT3_Trajectory/1_setup/optimization_config.json (TPE config)
- studies/M1_Mirror/SAT3_Trajectory/1_setup/model/ (all NX files copied from M1_Tensor)
- test_trajectory_extractor.py (validation script)

References:
- Physics: docs/physics/ZERNIKE_TRAJECTORY_METHOD.md
- Handoff: docs/handoff/SETUP_TRAJECTORY_OPTIMIZATION.md
- Extractor: optimization_engine/extractors/extract_zernike_trajectory.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 12:10:02 -05:00
af195c3a75 docs: add handoff document for trajectory optimization setup 2026-01-29 16:46:55 +00:00
5d69b3bd10 docs: add Zernike trajectory method documentation + example config 2026-01-29 16:32:05 +00:00
5dec327988 fix(extractors): trajectory extractor working with auto angle detection + validation 2026-01-29 16:28:53 +00:00
99be370fad feat(extractors): add Zernike trajectory analysis for mode-specific optimization 2026-01-29 16:02:07 +00:00
d7986922d5 fix(tools): make Zernike OPD tools robust to extra subcases
- Replace brittle order-based subcase mapping with name-based search
- Tools now directly search for required angles (20, 40, 60, 90) by label
- Ignores extra subcases (e.g., 30, 50 degrees) without errors
- Falls back to numeric IDs (1,2,3,4) if angle labels not found
- Clear error messages show exactly which subcases are missing

This allows running WFE analysis on simulations with >4 subcases
without manual file/code modifications.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 10:22:33 -05:00
a7039c5875 feat(draft): add local autosave + restore prompt + publish label 2026-01-29 03:16:31 +00:00
b3f3329c79 docs: update status + next sprint focus (Draft+Publish, Create Wizard) 2026-01-29 03:10:07 +00:00
f47b390ed7 feat(canvas): project edges from objective/constraint source 2026-01-29 03:01:47 +00:00
993c1ff17f feat(ui): edit objective/constraint source in panel + UNSET wiring 2026-01-29 02:49:04 +00:00
e2cfa0a3d9 feat(canvas): prompt for extractor output on connect 2026-01-29 02:45:15 +00:00
00dd88599e feat(canvas): sync objective/constraint source on edge connect/delete 2026-01-29 02:39:45 +00:00
4a7422c620 feat(canvas): add AtomizerSpec→ReactFlow converters 2026-01-29 02:37:32 +00:00
bb27f3fb00 docs: add QUICK_REF + workflow OS + 2026Q1 roadmap 2026-01-29 02:28:02 +00:00
a26914bbe8 feat: Add Studio UI, intake system, and extractor improvements
Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:02:30 -05:00
3193831340 feat: Add DevLoop automation and HTML Reports
## DevLoop - Closed-Loop Development System
- Orchestrator for plan → build → test → analyze cycle
- Gemini planning via OpenCode CLI
- Claude implementation via CLI bridge
- Playwright browser testing integration
- Test runner with API, filesystem, and browser tests
- Persistent state in .devloop/ directory
- CLI tool: tools/devloop_cli.py

Usage:
  python tools/devloop_cli.py start 'Create new feature'
  python tools/devloop_cli.py plan 'Fix bug in X'
  python tools/devloop_cli.py test --study support_arm
  python tools/devloop_cli.py browser --level full

## HTML Reports (optimization_engine/reporting/)
- Interactive Plotly-based reports
- Convergence plot, Pareto front, parallel coordinates
- Parameter importance analysis
- Self-contained HTML (offline-capable)
- Tailwind CSS styling

## Playwright E2E Tests
- Home page tests
- Test results in test-results/

## LAC Knowledge Base Updates
- Session insights (failures, workarounds, patterns)
- Optimization memory for arm support study
2026-01-24 21:18:18 -05:00
a3f18dc377 chore: Project cleanup and Canvas UX improvements (Phase 7-9)
## Cleanup (v0.5.0)
- Delete 102+ orphaned MCP session temp files
- Remove build artifacts (htmlcov, dist, __pycache__)
- Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Move debug/analysis scripts from tests/ to tools/analysis/
- Archive redundant NX journals to archive/nx_journals/
- Archive monolithic PROTOCOL.md to docs/archive/
- Update .gitignore with missing patterns
- Clean old study files (optimization_log_old.txt, run_optimization_old.py)

## Canvas UX (Phases 7-9)
- Phase 7: Resizable panels with localStorage persistence
  - Left sidebar: 200-400px, Right panel: 280-600px
  - New useResizablePanel hook and ResizeHandle component
- Phase 8: Enable all palette items
  - All 8 node types now draggable
  - Singleton logic for model/solver/algorithm/surrogate
- Phase 9: Solver configuration
  - Add SolverEngine type (nxnastran, mscnastran, python, etc.)
  - Add NastranSolutionType (SOL101-SOL200)
  - Engine/solution dropdowns in config panel
  - Python script path support

## Documentation
- Update CHANGELOG.md with recent versions
- Update docs/00_INDEX.md
- Create examples/README.md
- Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
2026-01-24 15:17:34 -05:00
2cb8dccc3a feat: Add WebSocket live updates and convergence visualization
Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
2026-01-21 21:48:35 -05:00
c224b16ac3 feat: Add panel management, validation, and error handling to canvas
Phase 1 - Panel Management System:
- Create usePanelStore.ts for centralized panel state management
- Add PanelContainer.tsx for draggable floating panels
- Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click)
- Create ResultsPanel.tsx for trial result details
- Refactor NodeConfigPanelV2 to use panel store for introspection
- Integrate PanelContainer into CanvasView

Phase 2 - Pre-run Validation:
- Create specValidator.ts with comprehensive validation rules
- Add ValidationPanel (enhanced version with error navigation)
- Add Validate button to SpecRenderer with status indicator
- Block run if validation fails
- Check for: design vars, objectives, extractors, bounds, connections

Phase 3 - Error Handling & Recovery:
- Create ErrorPanel.tsx for displaying optimization errors
- Add error classification (nx_crash, solver_fail, extractor_error, etc.)
- Add recovery suggestions based on error type
- Update status endpoint to return error info
- Add _get_study_error_info helper to check error_status.json and DB
- Integrate error detection into status polling

Documentation:
- Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
2026-01-21 21:35:31 -05:00
e1c59a51c1 feat: Add optimization execution and live results overlay to canvas
Phase 2 - Execution Bridge:
- Update /start endpoint to fallback to generic runner when no study script exists
- Auto-detect model files (.prt, .sim) from 1_setup/model/ directory
- Pass atomizer_spec.json path to generic runner

Phase 3 - Live Monitoring & Results Overlay:
- Add ResultBadge component for displaying values on canvas nodes
- Extend schema with resultValue and isFeasible fields
- Update DesignVarNode, ObjectiveNode, ConstraintNode, ExtractorNode to show results
- Add Run/Stop buttons and Results toggle to SpecRenderer
- Poll /status endpoint every 3s and map best_trial values to nodes
- Show green/red badges for constraint feasibility
2026-01-21 21:21:47 -05:00
f725e75164 feat: Add SIM file introspection journal and enhanced file-type specific UI
- Create introspect_sim.py NX journal to extract solutions, BCs from SIM files
- Update introspect_sim_file() to optionally call NX journal for full introspection
- Add FEM mesh section (nodes, elements, materials, properties) to IntrospectionPanel
- Add SIM solutions and boundary conditions sections to IntrospectionPanel
- Show introspection method and NX errors in panel
2026-01-20 21:20:14 -05:00
e954b130f5 feat: Multi-file introspection for FEM/SIM/PRT with PyNastran parsing 2026-01-20 21:14:16 -05:00
5b22439357 feat: Add part selector dropdown to IntrospectionPanel
- Fetch available parts from /nx/parts on panel mount
- Dropdown to select which part to introspect (default = assembly)
- Hides idealized parts (*_i.prt) from dropdown
- Shows part size in dropdown (KB or MB)
- Header shows selected part name in primary color
- Refresh button respects current part selection
- Auto-introspects when part selection changes
2026-01-20 21:04:36 -05:00
0c252e3a65 feat: Add sub-part introspection and existing FEA results UI
Backend:
- GET /nx/parts - List all .prt files in model directory
- GET /nx/introspect/{part_name} - Introspect a specific part file
  (e.g., M1_Blank.prt instead of just the assembly)
- Each part gets its own cache file (_introspection_{stem}.json)

Frontend IntrospectionPanel:
- Add 'FEA Results' section showing existing OP2/F06 sources
- Green checkmark when results exist, shows recommended source
- Expand file_deps and fea_results sections by default
- Add CheckCircle2 and Database icons

This allows introspecting component parts that contain the actual
design variable expressions (e.g., M1_Blank has 56 expressions
while the assembly ASSY_M1 only has 5).
2026-01-20 20:59:04 -05:00
4749944a48 feat: Add extract endpoint to use existing FEA results without re-solving
- scan_existing_fea_results() scans study for existing OP2/F06 files
- Introspection now returns existing_fea_results with recommended source
- New POST /nx/extract endpoint runs extractors on existing OP2 files
- Supports: displacement, stress, frequency, mass_bdf, zernike
- No NX solve needed - uses PyNastran and Atomizer extractors directly

This allows users to test extractors and get physics data from existing
simulation results without re-running the FEA solver.
2026-01-20 20:51:25 -05:00
3229c31349 fix: Rewrite run-baseline to use NXSolver iteration folder pattern
- Use same approach as run_optimization.py with use_iteration_folders=True
- NXSolver.create_iteration_folder() handles proper file copying
- Read NX settings from atomizer_spec.json or optimization_config.json
- Extract Nastran version from NX install path
- Creates iter0 folder for baseline (consistent with optimization numbering)

This fixes the issue where manually copying files didn't preserve
NX file dependency chain (.sim -> .afm -> .fem -> _i.prt -> .prt)
2026-01-20 19:06:40 -05:00
14354a2606 feat: Add NX file dependency tree to introspection panel
Backend:
- Add scan_nx_file_dependencies() function to parse NX file chain
- Uses naming conventions to build dependency tree (.sim -> .afm -> .fem -> _i.prt -> .prt)
- Include file_dependencies in introspection response
- Works without NX (pure file-based analysis)

Frontend:
- Add FileDependencies interface for typed API response
- Add collapsible 'File Dependencies' section with tree visualization
- Color-coded file types (purple=sim, blue=afm, green=fem, yellow=idealized, orange=prt)
- Shows orphan geometry files that aren't in the dependency chain
2026-01-20 15:33:04 -05:00
abbc7b1b50 feat: Add detailed Nastran memory error detection in run-baseline
- Parse Nastran log file to detect memory allocation failures
- Extract requested vs available memory from log
- Provide actionable error message with specific values
- Include log files in result_files response
2026-01-20 15:29:29 -05:00
1cdcc17ffd fix: NX installation path detection for run-baseline endpoint
- Read nx_install_path from atomizer_spec.json if available
- Auto-detect from common Siemens installation paths
- Fixes issue where NX2512 wasn't found (actual path is DesigncenterNX2512)
2026-01-20 15:23:10 -05:00
5c419e2358 fix(canvas): Multiple fixes for drag-drop, undo/redo, and code generation
Drag-drop fixes:
- Fix Objective default data: use nested 'source' object with extractor_id/output_name
- Fix Constraint default data: use 'type' field (not constraint_type), 'threshold' (not limit)

Undo/Redo fixes:
- Remove dependency on isDirty flag (which is always false due to auto-save)
- Record snapshots based on actual spec changes via deep comparison

Code generation improvements:
- Update system prompt to support multiple extractor types:
  * OP2-based extractors for FEA results (stress, displacement, frequency)
  * Expression-based extractors for NX model values (dimensions, volumes)
  * Computed extractors for derived values (no FEA needed)
- Claude will now choose appropriate signature based on user's description
2026-01-20 15:08:49 -05:00
89694088a2 feat(canvas): Add 'Run Baseline' FEA simulation feature to IntrospectionPanel
Backend:
- Add POST /api/optimization/studies/{study_id}/nx/run-baseline endpoint
- Creates trial_baseline folder in 2_iterations/
- Copies all model files and runs NXSolver
- Returns paths to result files (.op2, .f06, .bdf) for extractor testing

Frontend:
- Add 'Run Baseline Simulation' button to IntrospectionPanel
- Show progress spinner during simulation
- Display result files when complete (OP2, F06, BDF)
- Show error messages if simulation fails

This enables:
- Testing custom extractors against real FEA results
- Validating the simulation pipeline before optimization
- Inspecting boundary conditions and loads
2026-01-20 14:50:50 -05:00
91cf9ca1fd fix(canvas): Add Save/Reload buttons and expand IntrospectionPanel to show all model data
CanvasView:
- Fix Save button visibility - now shows when spec is loaded (grayed if no changes)
- Separate logic for spec mode vs legacy mode save buttons
- Fix Reload button visibility

IntrospectionPanel:
- Add Mass Properties section (mass, volume, surface area, CoG, body count)
- Add Linked Parts section showing file dependencies
- Add Bodies section (solid/sheet body counts)
- Add Units section showing unit system
- Type-safe access to all nested properties
2026-01-20 14:47:09 -05:00
ced79b8d39 fix(canvas): Fix IntrospectionPanel to handle new NX introspection API response format
- Handle expressions as object with user/internal arrays (new format) or direct array (old)
- Add useMemo for expression filtering
- Make extractors_available, dependent_files, warnings optional with safe access
- Support both 'unit' and 'units' field names
2026-01-20 14:26:20 -05:00
2f0f45de86 fix(spec): Correct volume extractor structure in m1_mirror_cost_reduction_lateral
- Change custom_function.code to function.source_code per AtomizerSpec v2.0 schema
2026-01-20 14:14:20 -05:00
47f8b50112 fix(canvas): Bug fixes for node movement, drag-drop, config panel, and introspection
- SpecRenderer: Add localNodes state with applyNodeChanges for smooth node dragging
- SpecRenderer: Fix getDefaultNodeData() - extractor uses 'custom_function' type with function definition
- SpecRenderer: Fix constraint default - use constraint_type instead of type
- CanvasView: Show config panel INSTEAD of chat when node selected (not blocked)
- NodeConfigPanelV2: Enable showHeader for code editor toolbar (Generate/Snippets/Validate/Test buttons)
- NodeConfigPanelV2: Pass studyId to IntrospectionPanel
- IntrospectionPanel: Accept studyId prop and use correct API endpoint
- optimization.py: Search multiple directories for model files including 1_setup/model/
2026-01-20 14:14:14 -05:00
cf8c57fdac chore: Add Atomizer launcher and utility scripts
- atomizer.ico: Application icon
- launch_atomizer.bat: One-click launcher for dashboard
- create_desktop_shortcut.ps1: Desktop shortcut creator
- restart_backend.bat, start_backend_8002.bat: Dev utilities
2026-01-20 13:12:12 -05:00
6c30224341 feat(config): AtomizerSpec v2.0 Pydantic models, validators, and tests
Config Layer:
- spec_models.py: Pydantic models for AtomizerSpec v2.0
- spec_validator.py: Semantic validation with detailed error reporting

Extractors:
- custom_extractor_loader.py: Runtime custom extractor loading
- spec_extractor_builder.py: Build extractors from spec definitions

Tools:
- migrate_to_spec_v2.py: CLI tool for batch migration

Tests:
- test_migrator.py: Migration tests
- test_spec_manager.py: SpecManager service tests
- test_spec_api.py: REST API tests
- test_mcp_tools.py: MCP tool tests
- test_e2e_unified_config.py: End-to-end config tests
2026-01-20 13:12:03 -05:00
27e78d3d56 feat(canvas): Custom extractor components, migrator, and MCP spec tools
Canvas Components:
- CustomExtractorNode.tsx: Node for custom Python extractors
- CustomExtractorPanel.tsx: Configuration panel for custom extractors
- ConnectionStatusIndicator.tsx: WebSocket status display
- atomizer-spec.ts: TypeScript types for AtomizerSpec v2.0

Config:
- migrator.py: Legacy config to AtomizerSpec v2.0 migration
- Updated __init__.py exports for config and extractors

MCP Tools:
- spec.ts: MCP tools for spec manipulation
- index.ts: Tool registration updates
2026-01-20 13:11:42 -05:00
cb6b130908 feat(config): Add AtomizerSpec v2.0 schema and migrate all studies
Added JSON Schema:
- optimization_engine/schemas/atomizer_spec_v2.json

Migrated 28 studies to AtomizerSpec v2.0 format:
- Drone_Gimbal studies (1)
- M1_Mirror studies (21)
- M2_Mirror studies (2)
- SheetMetal_Bracket studies (4)

Each atomizer_spec.json is the unified configuration containing:
- Design variables with bounds and expressions
- Extractors (standard and custom)
- Objectives and constraints
- Optimization algorithm settings
- Canvas layout information
2026-01-20 13:11:23 -05:00
f067497e08 refactor(dashboard): Remove unused Plotly components
Removed plotly/ directory with unused chart wrappers:
- PlotlyConvergencePlot, PlotlyCorrelationHeatmap
- PlotlyFeasibilityChart, PlotlyParallelCoordinates
- PlotlyParameterImportance, PlotlyParetoPlot
- PlotlyRunComparison, PlotlySurrogateQuality

These were replaced by Recharts-based implementations.
2026-01-20 13:11:02 -05:00
ba0b9a1fae feat(dashboard): Enhanced chat, spec management, and Claude integration
Backend:
- spec.py: New AtomizerSpec REST API endpoints
- spec_manager.py: SpecManager service for unified config
- interview_engine.py: Study creation interview logic
- claude.py: Enhanced Claude API with context
- optimization.py: Extended optimization endpoints
- context_builder.py, session_manager.py: Improved services

Frontend:
- Chat components: Enhanced message rendering, tool call cards
- Hooks: useClaudeCode, useSpecWebSocket, improved useChat
- Pages: Updated Dashboard, Analysis, Insights, Setup, Home
- Components: ParallelCoordinatesPlot, ParetoPlot improvements
- App.tsx: Route updates for canvas/studio

Infrastructure:
- vite.config.ts: Build configuration updates
- start/stop-dashboard.bat: Script improvements
2026-01-20 13:10:47 -05:00
1098 changed files with 900514 additions and 30495 deletions

View File

@@ -1 +0,0 @@
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}

View File

@@ -1 +0,0 @@
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}

View File

@@ -1,45 +0,0 @@
# Atomizer Assistant
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
**Current Mode**: USER
Your role:
- Help engineers with FEA optimization workflows
- Create, configure, and run optimization studies
- Analyze results and provide insights
- Explain FEA concepts and methodology
Important guidelines:
- Be concise and professional
- Use technical language appropriate for engineers
- You are "Atomizer Assistant", not a generic AI
- Use the available MCP tools to perform actions
- When asked about studies, use the appropriate tools to get real data
---
# Current Study: m1_mirror_flatback_lateral
**Status**: Study directory not found.
---
# User Mode Instructions
You can help with optimization workflows:
- Create and configure studies
- Run optimizations
- Analyze results
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`

View File

@@ -1,45 +0,0 @@
# Atomizer Assistant
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
**Current Mode**: USER
Your role:
- Help engineers with FEA optimization workflows
- Create, configure, and run optimization studies
- Analyze results and provide insights
- Explain FEA concepts and methodology
Important guidelines:
- Be concise and professional
- Use technical language appropriate for engineers
- You are "Atomizer Assistant", not a generic AI
- Use the available MCP tools to perform actions
- When asked about studies, use the appropriate tools to get real data
---
# Current Study: m1_mirror_flatback_lateral
**Status**: Study directory not found.
---
# User Mode Instructions
You can help with optimization workflows:
- Create and configure studies
- Run optimizations
- Analyze results
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`

View File

@@ -62,7 +62,26 @@
"Bash(xargs -I{} git ls-tree -r -l HEAD {})",
"Bash(sort:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe introspect_model.py)",
"Bash(xargs:*)"
"Bash(xargs:*)",
"Bash(ping:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import requests; r = requests.post\\(''http://127.0.0.1:8001/api/claude/sessions'', json={''mode'': ''user''}\\); print\\(f''Status: {r.status_code}''\\); print\\(f''Response: {r.text}''\\)\")",
"Bash(start \"Atomizer Backend\" cmd /k C:UsersantoiAtomizerrestart_backend.bat)",
"Bash(start \"Test Backend\" cmd /c \"cd /d C:\\\\Users\\\\antoi\\\\Atomizer\\\\atomizer-dashboard\\\\backend && C:\\\\Users\\\\antoi\\\\anaconda3\\\\Scripts\\\\activate.bat atomizer && python -m uvicorn api.main:app --port 8002\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe C:UsersantoiAtomizertest_backend.py)",
"Bash(start \"Backend 8002\" C:UsersantoiAtomizerstart_backend_8002.bat)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from api.main import app; print\\(''Import OK''\\)\")",
"Bash(find:*)",
"Bash(npx tailwindcss:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from pathlib import Path; p = Path\\(''C:/Users/antoi/Atomizer/studies''\\) / ''M1_Mirror/m1_mirror_cost_reduction_lateral''; print\\(''exists:'', p.exists\\(\\), ''path:'', p\\)\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\(''Study:'', d.get\\(''meta'',{}\\).get\\(''study_name'',''N/A''\\)\\); print\\(''Design Variables:''\\); [print\\(f'' - {dv[\"\"name\"\"]} \\({dv[\"\"expression_name\"\"]}\\)''\\) for dv in d.get\\(''design_variables'',[]\\)]\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -m py_compile:*)",
"Skill(ralph-loop:ralph-loop)",
"Skill(ralph-loop:ralph-loop:*)",
"mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__navigate",
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe -m pip install:*)",
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe tests/compare_triangle_vs_gmsh.py)",
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe:*)"
],
"deny": [],
"ask": []

View File

@@ -1,7 +1,7 @@
---
skill_id: SKILL_001
version: 2.4
last_updated: 2025-12-31
version: 2.5
last_updated: 2026-01-22
type: reference
code_dependencies:
- optimization_engine/extractors/__init__.py
@@ -14,8 +14,8 @@ requires_skills:
# Atomizer Quick Reference Cheatsheet
**Version**: 2.4
**Updated**: 2025-12-31
**Version**: 2.5
**Updated**: 2026-01-22
**Purpose**: Rapid lookup for common operations. "I want X → Use Y"
---
@@ -37,6 +37,8 @@ requires_skills:
| **Use SAT (Self-Aware Turbo)** | **SYS_16** | SAT v3 for high-efficiency neural-accelerated optimization |
| Generate physics insight | SYS_17 | `python -m optimization_engine.insights generate <study>` |
| **Manage knowledge/playbook** | **SYS_18** | `from optimization_engine.context import AtomizerPlaybook` |
| **Automate dev tasks** | **DevLoop** | `python tools/devloop_cli.py start "task"` |
| **Test dashboard UI** | **DevLoop** | `python tools/devloop_cli.py browser --level full` |
---
@@ -678,6 +680,67 @@ feedback.process_trial_result(
---
## DevLoop Quick Reference
Closed-loop development system using AI agents + Playwright testing.
### CLI Commands
| Task | Command |
|------|---------|
| Full dev cycle | `python tools/devloop_cli.py start "Create new study"` |
| Plan only | `python tools/devloop_cli.py plan "Fix validation"` |
| Implement plan | `python tools/devloop_cli.py implement` |
| Test study files | `python tools/devloop_cli.py test --study support_arm` |
| Analyze failures | `python tools/devloop_cli.py analyze` |
| Browser smoke test | `python tools/devloop_cli.py browser` |
| Browser full tests | `python tools/devloop_cli.py browser --level full` |
| Check status | `python tools/devloop_cli.py status` |
| Quick test | `python tools/devloop_cli.py quick` |
### Browser Test Levels
| Level | Description | Tests |
|-------|-------------|-------|
| `quick` | Smoke test (page loads) | 1 |
| `home` | Home page verification | 2 |
| `full` | All UI + study tests | 5+ |
| `study` | Canvas/dashboard for specific study | 3 |
### State Files (`.devloop/`)
| File | Purpose |
|------|---------|
| `current_plan.json` | Current implementation plan |
| `test_results.json` | Filesystem/API test results |
| `browser_test_results.json` | Playwright test results |
| `analysis.json` | Failure analysis |
### Prerequisites
```bash
# Start backend
cd atomizer-dashboard/backend && python -m uvicorn api.main:app --reload --port 8000
# Start frontend
cd atomizer-dashboard/frontend && npm run dev
# Install Playwright (once)
cd atomizer-dashboard/frontend && npx playwright install chromium
```
### Standalone Playwright Tests
```bash
cd atomizer-dashboard/frontend
npm run test:e2e # Run all E2E tests
npm run test:e2e:ui # Playwright UI mode
```
**Full documentation**: `docs/guides/DEVLOOP.md`
---
## Report Generation Quick Reference (OP_08)
Generate comprehensive study reports from optimization data.

View File

@@ -0,0 +1,206 @@
# Study README Generator Skill
**Skill ID**: STUDY_README_GENERATOR
**Version**: 1.0
**Purpose**: Generate intelligent, context-aware README.md files for optimization studies
## When to Use
This skill is invoked automatically during the study intake workflow when:
1. A study moves from `introspected` to `configured` status
2. User explicitly requests README generation
3. Finalizing a study from the inbox
## Input Context
The README generator receives:
```json
{
"study_name": "bracket_mass_opt_v1",
"topic": "Brackets",
"description": "User's description from intake form",
"spec": { /* Full AtomizerSpec v2.0 */ },
"introspection": {
"expressions": [...],
"mass_kg": 1.234,
"solver_type": "NX_Nastran"
},
"context_files": {
"goals.md": "User's goals markdown content",
"notes.txt": "Any additional notes"
}
}
```
## Output Format
Generate a README.md with these sections:
### 1. Title & Overview
```markdown
# {Study Name}
**Topic**: {Topic}
**Created**: {Date}
**Status**: {Status}
{One paragraph executive summary of the optimization goal}
```
### 2. Engineering Problem
```markdown
## Engineering Problem
{Describe the physical problem being solved}
### Model Description
- **Geometry**: {Describe the part/assembly}
- **Material**: {If known from introspection}
- **Baseline Mass**: {mass_kg} kg
### Loading Conditions
{Describe loads and boundary conditions if available}
```
### 3. Optimization Formulation
```markdown
## Optimization Formulation
### Design Variables ({count})
| Variable | Expression | Range | Units |
|----------|------------|-------|-------|
| {name} | {expr_name} | [{min}, {max}] | {units} |
### Objectives ({count})
| Objective | Direction | Weight | Source |
|-----------|-----------|--------|--------|
| {name} | {direction} | {weight} | {extractor} |
### Constraints ({count})
| Constraint | Condition | Threshold | Type |
|------------|-----------|-----------|------|
| {name} | {operator} | {threshold} | {type} |
```
### 4. Methodology
```markdown
## Methodology
### Algorithm
- **Primary**: {algorithm_type}
- **Max Trials**: {max_trials}
- **Surrogate**: {if enabled}
### Physics Extraction
{Describe extractors used}
### Convergence Criteria
{Describe stopping conditions}
```
### 5. Expected Outcomes
```markdown
## Expected Outcomes
Based on the optimization setup:
- Expected improvement: {estimate if baseline available}
- Key trade-offs: {identify from objectives/constraints}
- Risk factors: {any warnings from validation}
```
## Generation Guidelines
1. **Be Specific**: Use actual values from the spec, not placeholders
2. **Be Concise**: Engineers don't want to read novels
3. **Be Accurate**: Only state facts that can be verified from input
4. **Be Helpful**: Include insights that aid understanding
5. **No Fluff**: Avoid marketing language or excessive praise
## Claude Prompt Template
```
You are generating a README.md for an FEA optimization study.
CONTEXT:
{json_context}
RULES:
1. Use the actual data provided - never use placeholder values
2. Write in technical engineering language appropriate for structural engineers
3. Keep each section concise but complete
4. If information is missing, note it as "TBD" or skip the section
5. Include physical units wherever applicable
6. Format tables properly with alignment
Generate the README.md content:
```
## Example Output
```markdown
# Bracket Mass Optimization V1
**Topic**: Simple_Bracket
**Created**: 2026-01-22
**Status**: Configured
Optimize the mass of a structural L-bracket while maintaining stress below yield and displacement within tolerance.
## Engineering Problem
### Model Description
- **Geometry**: L-shaped mounting bracket with web and flange
- **Material**: Steel (assumed based on typical applications)
- **Baseline Mass**: 0.847 kg
### Loading Conditions
Static loading with force applied at mounting holes. Fixed constraints at base.
## Optimization Formulation
### Design Variables (3)
| Variable | Expression | Range | Units |
|----------|------------|-------|-------|
| Web Thickness | web_thickness | [2.0, 10.0] | mm |
| Flange Width | flange_width | [15.0, 40.0] | mm |
| Fillet Radius | fillet_radius | [2.0, 8.0] | mm |
### Objectives (1)
| Objective | Direction | Weight | Source |
|-----------|-----------|--------|--------|
| Total Mass | minimize | 1.0 | mass_extractor |
### Constraints (1)
| Constraint | Condition | Threshold | Type |
|------------|-----------|-----------|------|
| Max Stress | <= | 250 MPa | hard |
## Methodology
### Algorithm
- **Primary**: TPE (Tree-structured Parzen Estimator)
- **Max Trials**: 100
- **Surrogate**: Disabled
### Physics Extraction
- Mass: Extracted from NX expression `total_mass`
- Stress: Von Mises stress from SOL101 static analysis
### Convergence Criteria
- Max trials: 100
- Early stopping: 20 trials without improvement
## Expected Outcomes
Based on the optimization setup:
- Expected improvement: 15-30% mass reduction (typical for thickness optimization)
- Key trade-offs: Mass vs. stress margin
- Risk factors: None identified
```
## Integration Points
- **Backend**: `api/services/claude_readme.py` calls Claude API with this prompt
- **Endpoint**: `POST /api/intake/{study_name}/readme`
- **Trigger**: Automatic on status transition to `configured`

View File

@@ -0,0 +1,33 @@
{
"timestamp": "2026-01-22T18:13:30.884945",
"scenarios": [
{
"scenario_id": "browser_home_stats",
"scenario_name": "Home page shows statistics",
"passed": true,
"duration_ms": 1413.166,
"error": null,
"details": {
"navigated_to": "http://localhost:3003/",
"found_selector": "text=Total Trials"
}
},
{
"scenario_id": "browser_expand_folder",
"scenario_name": "Topic folder expands on click",
"passed": true,
"duration_ms": 2785.3219999999997,
"error": null,
"details": {
"navigated_to": "http://localhost:3003/",
"found_selector": "span:has-text('completed'), span:has-text('running'), span:has-text('paused')",
"clicked": "button:has-text('trials')"
}
}
],
"summary": {
"passed": 2,
"failed": 0,
"total": 2
}
}

View File

@@ -0,0 +1,16 @@
{
"objective": "Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md",
"approach": "Fallback plan - manual implementation",
"tasks": [
{
"id": "task_001",
"description": "Implement: Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md",
"file": "TBD",
"priority": "high"
}
],
"test_scenarios": [],
"acceptance_criteria": [
"Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md"
]
}

View File

@@ -0,0 +1,64 @@
{
"timestamp": "2026-01-22T21:10:54.742272",
"scenarios": [
{
"scenario_id": "test_study_dir",
"scenario_name": "Study directory exists: stage_3_arm",
"passed": true,
"duration_ms": 0.0,
"error": null,
"details": {
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm",
"exists": true
}
},
{
"scenario_id": "test_spec",
"scenario_name": "AtomizerSpec is valid JSON",
"passed": true,
"duration_ms": 1.045,
"error": null,
"details": {
"valid_json": true
}
},
{
"scenario_id": "test_readme",
"scenario_name": "README exists",
"passed": true,
"duration_ms": 0.0,
"error": null,
"details": {
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\README.md",
"exists": true
}
},
{
"scenario_id": "test_run_script",
"scenario_name": "run_optimization.py exists",
"passed": true,
"duration_ms": 0.0,
"error": null,
"details": {
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\run_optimization.py",
"exists": true
}
},
{
"scenario_id": "test_model_dir",
"scenario_name": "Model directory exists",
"passed": true,
"duration_ms": 0.0,
"error": null,
"details": {
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\1_setup\\model",
"exists": true
}
}
],
"summary": {
"passed": 5,
"failed": 0,
"total": 5
}
}

34
.gitignore vendored
View File

@@ -15,6 +15,11 @@ lib64/
parts/
sdist/
var/
# NOTE: This repo includes a React frontend that legitimately uses src/lib/.
# The broad Python ignore `lib/` would ignore that. Re-include it:
!atomizer-dashboard/frontend/src/lib/
!atomizer-dashboard/frontend/src/lib/**
wheels/
*.egg-info/
.installed.cfg
@@ -110,5 +115,34 @@ _dat_run*.dat
.claude-mcp-*.json
.claude-prompt-*.md
# Backend logs
backend_stdout.log
backend_stderr.log
*.log.bak
# Linter/formatter caches
.ruff_cache/
.mypy_cache/
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
docs/generated/
# NX model introspection caches (generated)
**/_introspection_*.json
**/_introspection_cache.json
**/_temp_introspection.json
**/params.exp
# Insight outputs (generated)
**/3_insights/
# Malformed filenames (Windows path used as filename)
C:*
*.gitmodules
# project-context-sync (auto-generated, local only)
PROJECT_STATE.md
# Test results (synced via Syncthing, not git)
test_results/*.json
test_results/*.log

View File

@@ -7,6 +7,10 @@
"ATOMIZER_MODE": "user",
"ATOMIZER_ROOT": "C:/Users/antoi/Atomizer"
}
},
"nxopen-docs": {
"command": "C:/Users/antoi/CADtomaste/Atomaste-NXOpen-MCP/.venv/Scripts/python.exe",
"args": ["-m", "nxopen_mcp.server", "--data-dir", "C:/Users/antoi/CADtomaste/Atomaste-NXOpen-MCP/data"]
}
}
}

21
.project-context.yml Normal file
View File

@@ -0,0 +1,21 @@
# project-context-sync configuration
# See: https://github.com/clawdbot/skills/project-context-sync
project_context:
# Use AI to generate smart summaries
# true: Rich context with inferred focus and suggestions (uses tokens)
# false: Raw git info only (fast, free)
ai_summary: true
# How many recent commits to show
recent_commits: 5
# Include file change stats in output
include_diff_stats: true
# Sections to include in PROJECT_STATE.md
sections:
- last_commit # Always included
- recent_changes # Recent commit list
- current_focus # AI-generated (requires ai_summary: true)
- suggested_next # AI-generated (requires ai_summary: true)

View File

@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.5.0] - 2025-01-24
### Project Cleanup & Organization
- Deleted 102+ orphaned MCP session temp files
- Removed build artifacts (htmlcov, dist, __pycache__)
- Archived superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Moved debug/analysis scripts from tests/ to tools/analysis/
- Updated .gitignore with missing patterns
- Cleaned empty directories
## [0.4.0] - 2025-01-22
### Canvas UX Improvements (Phases 7-9)
- **Resizable Panels**: Left sidebar (200-400px) and right panel (280-600px) with localStorage persistence
- **All Palette Items Enabled**: All 8 node types now draggable (model, solver, designVar, extractor, objective, constraint, algorithm, surrogate)
- **Solver Configuration**: Engine selection (NX Nastran, MSC Nastran, Python Script) with solution type dropdowns (SOL101-SOL200)
### AtomizerSpec v2.0
- Unified JSON configuration schema for all studies
- Added SolverEngine and NastranSolutionType types
- Canvas position persistence for all nodes
- Migration support from legacy optimization_config.json
## [0.3.0] - 2025-01-18
### Dashboard V3.1 - Canvas Builder
- Visual workflow builder with 9 node types
- Spec ↔ ReactFlow bidirectional converter
- WebSocket real-time synchronization
- Claude chat integration
- Custom extractors with in-canvas code editor
- Model introspection panel
### Learning Atomizer Core (LAC)
- Persistent memory system for accumulated knowledge
- Session insights recording (failures, workarounds, patterns)
- Optimization outcome tracking
## [0.2.5] - 2025-01-16
### GNN Surrogate for Zernike Optimization
- PolarMirrorGraph with fixed 3000-node polar grid
- ZernikeGNN model with design-conditioned convolutions
- Differentiable GPU-accelerated Zernike fitting
- Training pipeline with multi-task loss
### DevLoop Automation
- Closed-loop development system with AI agents
- Gemini planning, Claude implementation
- Playwright browser testing for dashboard UI
## [0.2.1] - 2025-01-07
### Optimization Engine v2.0 Restructure
- Reorganized into modular subpackages (core/, nx/, study/, config/)
- SpecManager for AtomizerSpec handling
- Deprecation warnings for old import paths
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
#### Added

View File

@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
- If no study context: Offer to create one or list available studies
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
### Step 5: Use DevLoop for Multi-Step Development Tasks
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
```bash
# Plan a task with Gemini
python tools/devloop_cli.py plan "fix extractor exports"
# Implement with Claude
python tools/devloop_cli.py implement
# Test filesystem/API
python tools/devloop_cli.py test --study support_arm
# Test dashboard UI with Playwright
python tools/devloop_cli.py browser --level full
# Analyze failures
python tools/devloop_cli.py analyze
# Full autonomous cycle
python tools/devloop_cli.py start "add new stress extractor"
```
**When to use DevLoop:**
- Fixing bugs that require multiple file changes
- Adding new features or extractors
- Debugging optimization failures
- Testing dashboard UI changes
- Any task that would take 3+ manual steps
**Browser test levels:**
- `quick` - Smoke test (1 test)
- `home` - Home page verification (2 tests)
- `full` - All UI tests (5+ tests)
- `study` - Canvas/dashboard for specific study
**DO NOT default to manual debugging** - use the automation we built!
**Full documentation**: `docs/guides/DEVLOOP.md`
---
## Quick Start - Protocol Operating System

File diff suppressed because one or more lines are too long

View File

@@ -1,619 +0,0 @@
# Atomizer Development Guide
**Last Updated**: 2025-11-21
**Current Phase**: Phase 3.2 - Integration Sprint + Documentation
**Status**: 🟢 Core Complete (100%) | ✅ Protocols 10/11/13 Active (100%) | 🎯 Dashboard Live (95%) | 📚 Documentation Reorganized
📘 **Quick Links**:
- [Protocol Specifications](docs/PROTOCOLS.md) - All active protocols consolidated
- [Documentation Index](docs/00_INDEX.md) - Complete documentation navigation
- [README](README.md) - Project overview and quick start
---
## Table of Contents
1. [Current Phase](#current-phase)
2. [Completed Features](#completed-features)
3. [Active Development](#active-development)
4. [Known Issues](#known-issues)
5. [Testing Status](#testing-status)
6. [Phase-by-Phase Progress](#phase-by-phase-progress)
---
## Current Phase
### Phase 3.2: Integration Sprint (🎯 TOP PRIORITY)
**Goal**: Connect LLM intelligence components to production workflow
**Timeline**: 2-4 weeks (Started 2025-11-17)
**Status**: LLM components built and tested individually (85% complete). Need to wire them into production runner.
📋 **Detailed Plan**: [docs/PHASE_3_2_INTEGRATION_PLAN.md](docs/PHASE_3_2_INTEGRATION_PLAN.md)
**Critical Path**:
#### Week 1: Make LLM Mode Accessible (16 hours)
- [ ] **1.1** Create unified entry point `optimization_engine/run_optimization.py` (4h)
- Add `--llm` flag for natural language mode
- Add `--request` parameter for natural language input
- Support both LLM and traditional JSON modes
- Preserve backward compatibility
- [ ] **1.2** Wire LLMOptimizationRunner to production (8h)
- Connect LLMWorkflowAnalyzer to entry point
- Bridge LLMOptimizationRunner → OptimizationRunner
- Pass model updater and simulation runner callables
- Integrate with existing hook system
- [ ] **1.3** Create minimal example (2h)
- Create `examples/llm_mode_demo.py`
- Show natural language → optimization results
- Compare traditional (100 lines) vs LLM (3 lines)
- [ ] **1.4** End-to-end integration test (2h)
- Test with simple_beam_optimization study
- Verify extractors generated correctly
- Validate output matches manual mode
#### Week 2: Robustness & Safety (16 hours)
- [ ] **2.1** Code validation pipeline (6h)
- Create `optimization_engine/code_validator.py`
- Implement syntax validation (ast.parse)
- Implement security scanning (whitelist imports)
- Implement test execution on example OP2
- Add retry with LLM feedback on failure
- [ ] **2.2** Graceful fallback mechanisms (4h)
- Wrap all LLM calls in try/except
- Provide clear error messages
- Offer fallback to manual mode
- Never crash on LLM failure
- [ ] **2.3** LLM audit trail (3h)
- Create `optimization_engine/llm_audit.py`
- Log all LLM requests and responses
- Log generated code with prompts
- Create `llm_audit.json` in study output
- [ ] **2.4** Failure scenario testing (3h)
- Test invalid natural language request
- Test LLM unavailable
- Test generated code syntax errors
- Test validation failures
#### Week 3: Learning System (12 hours)
- [ ] **3.1** Knowledge base implementation (4h)
- Create `optimization_engine/knowledge_base.py`
- Implement `save_session()` - Save successful workflows
- Implement `search_templates()` - Find similar patterns
- Add confidence scoring
- [ ] **3.2** Template extraction (4h)
- Extract reusable patterns from generated code
- Parameterize variable parts
- Save templates with usage examples
- Implement template application to new requests
- [ ] **3.3** ResearchAgent integration (4h)
- Complete ResearchAgent implementation
- Integrate into ExtractorOrchestrator error handling
- Add user example collection workflow
- Save learned knowledge to knowledge base
#### Week 4: Documentation & Discoverability (8 hours)
- [ ] **4.1** Update README (2h)
- Add "🤖 LLM-Powered Mode" section
- Show example command with natural language
- Link to detailed docs
- [ ] **4.2** Create LLM mode documentation (3h)
- Create `docs/LLM_MODE.md`
- Explain how LLM mode works
- Provide usage examples
- Add troubleshooting guide
- [ ] **4.3** Create demo video/GIF (1h)
- Record terminal session
- Show before/after (100 lines → 3 lines)
- Create animated GIF for README
- [ ] **4.4** Update all planning docs (2h)
- Update DEVELOPMENT.md status
- Update DEVELOPMENT_GUIDANCE.md (80-90% → 90-95%)
- Mark Phase 3.2 as ✅ Complete
---
## Completed Features
### ✅ Live Dashboard System (Completed 2025-11-21)
#### Backend (FastAPI + WebSocket)
- [x] **FastAPI Backend** ([atomizer-dashboard/backend/](atomizer-dashboard/backend/))
- REST API endpoints for study management
- WebSocket streaming with file watching (Watchdog)
- Real-time updates (<100ms latency)
- CORS configured for local development
- [x] **REST API Endpoints** ([backend/api/routes/optimization.py](atomizer-dashboard/backend/api/routes/optimization.py))
- `GET /api/optimization/studies` - List all studies
- `GET /api/optimization/studies/{id}/status` - Get study status
- `GET /api/optimization/studies/{id}/history` - Get trial history
- `GET /api/optimization/studies/{id}/pruning` - Get pruning diagnostics
- [x] **WebSocket Streaming** ([backend/api/websocket/optimization_stream.py](atomizer-dashboard/backend/api/websocket/optimization_stream.py))
- File watching on `optimization_history_incremental.json`
- Real-time trial updates via WebSocket
- Pruning alerts and progress updates
- Automatic observer lifecycle management
#### Frontend (HTML + Chart.js)
- [x] **Enhanced Live Dashboard** ([atomizer-dashboard/dashboard-enhanced.html](atomizer-dashboard/dashboard-enhanced.html))
- Real-time WebSocket updates
- Interactive convergence chart (Chart.js)
- Parameter space scatter plot
- Pruning alerts (toast notifications)
- Data export (JSON/CSV)
- Study auto-discovery and selection
- Metric dashboard (trials, best value, pruned count)
#### React Frontend (In Progress)
- [x] **Project Configuration** ([atomizer-dashboard/frontend/](atomizer-dashboard/frontend/))
- React 18 + Vite 5 + TypeScript 5.2
- TailwindCSS 3.3 for styling
- Recharts 2.10 for charts
- Complete build configuration
- [x] **TypeScript Types** ([frontend/src/types/](atomizer-dashboard/frontend/src/types/))
- Complete type definitions for API data
- WebSocket message types
- Chart data structures
- [x] **Custom Hooks** ([frontend/src/hooks/useWebSocket.ts](atomizer-dashboard/frontend/src/hooks/useWebSocket.ts))
- WebSocket connection management
- Auto-reconnection with exponential backoff
- Type-safe message routing
- [x] **Reusable Components** ([frontend/src/components/](atomizer-dashboard/frontend/src/components/))
- Card, MetricCard, Badge, StudyCard components
- TailwindCSS styling with dark theme
- [ ] **Dashboard Page** (Pending manual completion)
- Need to run `npm install`
- Create main.tsx, App.tsx, Dashboard.tsx
- Integrate Recharts for charts
- Test end-to-end
#### Documentation
- [x] **Dashboard Master Plan** ([docs/DASHBOARD_MASTER_PLAN.md](docs/DASHBOARD_MASTER_PLAN.md))
- Complete 3-page architecture (Configurator, Live Dashboard, Results Viewer)
- Tech stack recommendations
- Implementation phases
- [x] **Implementation Status** ([docs/DASHBOARD_IMPLEMENTATION_STATUS.md](docs/DASHBOARD_IMPLEMENTATION_STATUS.md))
- Current progress tracking
- Testing instructions
- Next steps
- [x] **React Implementation Guide** ([docs/DASHBOARD_REACT_IMPLEMENTATION.md](docs/DASHBOARD_REACT_IMPLEMENTATION.md))
- Complete templates for remaining components
- Recharts integration examples
- Troubleshooting guide
- [x] **Session Summary** ([docs/DASHBOARD_SESSION_SUMMARY.md](docs/DASHBOARD_SESSION_SUMMARY.md))
- Features demonstrated
- How to use the dashboard
- Architecture explanation
### ✅ Phase 1: Plugin System & Infrastructure (Completed 2025-01-16)
#### Core Architecture
- [x] **Hook Manager** ([optimization_engine/plugins/hook_manager.py](optimization_engine/plugins/hook_manager.py))
- Hook registration with priority-based execution
- Auto-discovery from plugin directories
- Context passing to all hooks
- Execution history tracking
- [x] **Lifecycle Hooks**
- `pre_solve`: Execute before solver launch
- `post_solve`: Execute after solve, before extraction
- `post_extraction`: Execute after result extraction
#### Logging Infrastructure
- [x] **Detailed Trial Logs** ([detailed_logger.py](optimization_engine/plugins/pre_solve/detailed_logger.py))
- Per-trial log files in `optimization_results/trial_logs/`
- Complete iteration trace with timestamps
- Design variables, configuration, timeline
- Extracted results and constraint evaluations
- [x] **High-Level Optimization Log** ([optimization_logger.py](optimization_engine/plugins/pre_solve/optimization_logger.py))
- `optimization.log` file tracking overall progress
- Configuration summary header
- Compact START/COMPLETE entries per trial
- Easy to scan format for monitoring
- [x] **Result Appenders**
- [log_solve_complete.py](optimization_engine/plugins/post_solve/log_solve_complete.py) - Appends solve completion to trial logs
- [log_results.py](optimization_engine/plugins/post_extraction/log_results.py) - Appends extracted results to trial logs
- [optimization_logger_results.py](optimization_engine/plugins/post_extraction/optimization_logger_results.py) - Appends results to optimization.log
#### Project Organization
- [x] **Studies Structure** ([studies/](studies/))
- Standardized folder layout with `model/`, `optimization_results/`, `analysis/`
- Comprehensive documentation in [studies/README.md](studies/README.md)
- Example study: [bracket_stress_minimization/](studies/bracket_stress_minimization/)
- Template structure for future studies
- [x] **Path Resolution** ([atomizer_paths.py](atomizer_paths.py))
- Intelligent project root detection using marker files
- Helper functions: `root()`, `optimization_engine()`, `studies()`, `tests()`
- `ensure_imports()` for robust module imports
- Works regardless of script location
#### Testing
- [x] **Hook Validation Test** ([test_hooks_with_bracket.py](tests/test_hooks_with_bracket.py))
- Verifies hook loading and execution
- Tests 3 trials with dummy data
- Checks hook execution history
- [x] **Integration Tests**
- [run_5trial_test.py](tests/run_5trial_test.py) - Quick 5-trial optimization
- [test_journal_optimization.py](tests/test_journal_optimization.py) - Full optimization test
#### Runner Enhancements
- [x] **Context Passing** ([runner.py:332,365,412](optimization_engine/runner.py))
- `output_dir` passed to all hook contexts
- Trial number, design variables, extracted results
- Configuration dictionary available to hooks
### ✅ Core Engine (Pre-Phase 1)
- [x] Optuna integration with TPE sampler
- [x] Multi-objective optimization support
- [x] NX journal execution ([nx_solver.py](optimization_engine/nx_solver.py))
- [x] Expression updates ([nx_updater.py](optimization_engine/nx_updater.py))
- [x] OP2 result extraction (stress, displacement)
- [x] Study management with resume capability
- [x] Web dashboard (real-time monitoring)
- [x] Precision control (4-decimal rounding)
---
## Active Development
### In Progress - Dashboard (High Priority)
- [x] Backend API complete (FastAPI + WebSocket)
- [x] HTML dashboard with Chart.js complete
- [x] React project structure and configuration complete
- [ ] **Complete React frontend** (Awaiting manual npm install)
- [ ] Run `npm install` in frontend directory
- [ ] Create main.tsx and App.tsx
- [ ] Create Dashboard.tsx with Recharts
- [ ] Test end-to-end with live optimization
### Up Next - Dashboard (Next Session)
- [ ] Study Configurator page (React)
- [ ] Results Report Viewer page (React)
- [ ] LLM chat interface integration (future)
- [ ] Docker deployment configuration
### In Progress - Phase 3.2 Integration
- [ ] Feature registry creation (Phase 2, Week 1)
- [ ] Claude skill definition (Phase 2, Week 1)
### Up Next (Phase 2, Week 2)
- [ ] Natural language parser
- [ ] Intent classification system
- [ ] Entity extraction for optimization parameters
- [ ] Conversational workflow manager
### Backlog (Phase 3+)
- [ ] Custom function generator (RSS, weighted objectives)
- [ ] Journal script generator
- [ ] Code validation pipeline
- [ ] Result analyzer with statistical analysis
- [ ] Surrogate quality checker
- [ ] HTML/PDF report generator
---
## Known Issues
### Critical
- None currently
### Minor
- [ ] `.claude/settings.local.json` modified during development (contains user-specific settings)
- [ ] Some old bash background processes still running from previous tests
### Documentation
- [ ] Need to add examples of custom hooks to studies/README.md
- [ ] Missing API documentation for hook_manager methods
- [ ] No developer guide for creating new plugins
---
## Testing Status
### Automated Tests
-**Hook system** - `test_hooks_with_bracket.py` passing
-**5-trial integration** - `run_5trial_test.py` working
-**Full optimization** - `test_journal_optimization.py` functional
-**Unit tests** - Need to create for individual modules
-**CI/CD pipeline** - Not yet set up
### Manual Testing
- ✅ Bracket optimization (50 trials)
- ✅ Log file generation in correct locations
- ✅ Hook execution at all lifecycle points
- ✅ Path resolution across different script locations
-**Dashboard backend** - REST API and WebSocket tested successfully
-**HTML dashboard** - Live updates working with Chart.js
-**React dashboard** - Pending npm install and completion
- ⏳ Resume functionality with config validation
### Test Coverage
- Hook manager: ~80% (core functionality tested)
- Logging plugins: 100% (tested via integration tests)
- Path resolution: 100% (tested in all scripts)
- Result extractors: ~70% (basic tests exist)
- **Dashboard backend**: ~90% (REST endpoints and WebSocket tested)
- **Dashboard frontend**: ~60% (HTML version tested, React pending)
- Overall: ~65% estimated
---
## Phase-by-Phase Progress
### Phase 1: Plugin System ✅ (100% Complete)
**Completed** (2025-01-16):
- [x] Hook system for optimization lifecycle
- [x] Plugin auto-discovery and registration
- [x] Hook manager with priority-based execution
- [x] Detailed per-trial logs (`trial_logs/`)
- [x] High-level optimization log (`optimization.log`)
- [x] Context passing system for hooks
- [x] Studies folder structure
- [x] Comprehensive studies documentation
- [x] Model file organization (`model/` folder)
- [x] Intelligent path resolution
- [x] Test suite for hook system
**Deferred to Future Phases**:
- Feature registry → Phase 2 (with LLM interface)
- `pre_mesh` and `post_mesh` hooks → Future (not needed for current workflow)
- Custom objective/constraint registration → Phase 3 (Code Generation)
---
### Phase 2: LLM Integration 🟡 (0% Complete)
**Target**: 2 weeks (Started 2025-01-16)
#### Week 1 Todos (Feature Registry & Claude Skill)
- [ ] Create `optimization_engine/feature_registry.json`
- [ ] Extract all current capabilities
- [ ] Draft `.claude/skills/atomizer.md`
- [ ] Test LLM's ability to navigate codebase
#### Week 2 Todos (Natural Language Interface)
- [ ] Implement intent classifier
- [ ] Build entity extractor
- [ ] Create workflow manager
- [ ] Test end-to-end: "Create a stress minimization study"
**Success Criteria**:
- [ ] LLM can create optimization from natural language in <5 turns
- [ ] 90% of user requests understood correctly
- [ ] Zero manual JSON editing required
---
### Phase 3: Code Generation ⏳ (Not Started)
**Target**: 3 weeks
**Key Deliverables**:
- [ ] Custom function generator
- [ ] RSS (Root Sum Square) template
- [ ] Weighted objectives template
- [ ] Custom constraints template
- [ ] Journal script generator
- [ ] Code validation pipeline
- [ ] Safe execution environment
**Success Criteria**:
- [ ] LLM generates 10+ custom functions with zero errors
- [ ] All generated code passes safety validation
- [ ] Users save 50% time vs. manual coding
---
### Phase 4: Analysis & Decision Support ⏳ (Not Started)
**Target**: 3 weeks
**Key Deliverables**:
- [ ] Result analyzer (convergence, sensitivity, outliers)
- [ ] Surrogate model quality checker (R², CV score, confidence intervals)
- [ ] Decision assistant (trade-offs, what-if analysis, recommendations)
**Success Criteria**:
- [ ] Surrogate quality detection 95% accurate
- [ ] Recommendations lead to 30% faster convergence
- [ ] Users report higher confidence in results
---
### Phase 5: Automated Reporting ⏳ (Not Started)
**Target**: 2 weeks
**Key Deliverables**:
- [ ] Report generator with Jinja2 templates
- [ ] Multi-format export (HTML, PDF, Markdown, JSON)
- [ ] LLM-written narrative explanations
**Success Criteria**:
- [ ] Reports generated in <30 seconds
- [ ] Narrative quality rated 4/5 by engineers
- [ ] 80% of reports used without manual editing
---
### Phase 6: NX MCP Enhancement ⏳ (Not Started)
**Target**: 4 weeks
**Key Deliverables**:
- [ ] NX documentation MCP server
- [ ] Advanced NX operations library
- [ ] Feature bank with 50+ pre-built operations
**Success Criteria**:
- [ ] NX MCP answers 95% of API questions correctly
- [ ] Feature bank covers 80% of common workflows
- [ ] Users write 50% less manual journal code
---
### Phase 7: Self-Improving System ⏳ (Not Started)
**Target**: 4 weeks
**Key Deliverables**:
- [ ] Feature learning system
- [ ] Best practices database
- [ ] Continuous documentation generation
**Success Criteria**:
- [ ] 20+ user-contributed features in library
- [ ] Pattern recognition identifies 10+ best practices
- [ ] Documentation auto-updates with zero manual effort
---
## Development Commands
### Running Dashboard
```bash
# Start backend server
cd atomizer-dashboard/backend
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
# Access HTML dashboard (current)
# Open browser: http://localhost:8000
# Start React frontend (when ready)
cd atomizer-dashboard/frontend
npm install # First time only
npm run dev # Starts on http://localhost:3000
```
### Running Tests
```bash
# Hook validation (3 trials, fast)
python tests/test_hooks_with_bracket.py
# Quick integration test (5 trials)
python tests/run_5trial_test.py
# Full optimization test
python tests/test_journal_optimization.py
```
### Code Quality
```bash
# Run linter (when available)
# pylint optimization_engine/
# Run type checker (when available)
# mypy optimization_engine/
# Run all tests (when test suite is complete)
# pytest tests/
```
### Git Workflow
```bash
# Stage all changes
git add .
# Commit with conventional commits format
git commit -m "feat: description" # New feature
git commit -m "fix: description" # Bug fix
git commit -m "docs: description" # Documentation
git commit -m "test: description" # Tests
git commit -m "refactor: description" # Code refactoring
# Push to GitHub
git push origin main
```
---
## Documentation
### For Developers
- [DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md) - Strategic vision and phases
- [studies/README.md](studies/README.md) - Studies folder organization
- [CHANGELOG.md](CHANGELOG.md) - Version history
### Dashboard Documentation
- [docs/DASHBOARD_MASTER_PLAN.md](docs/DASHBOARD_MASTER_PLAN.md) - Complete architecture blueprint
- [docs/DASHBOARD_IMPLEMENTATION_STATUS.md](docs/DASHBOARD_IMPLEMENTATION_STATUS.md) - Current progress
- [docs/DASHBOARD_REACT_IMPLEMENTATION.md](docs/DASHBOARD_REACT_IMPLEMENTATION.md) - React implementation guide
- [docs/DASHBOARD_SESSION_SUMMARY.md](docs/DASHBOARD_SESSION_SUMMARY.md) - Session summary
- [atomizer-dashboard/README.md](atomizer-dashboard/README.md) - Dashboard quick start
- [atomizer-dashboard/backend/README.md](atomizer-dashboard/backend/README.md) - Backend API docs
- [atomizer-dashboard/frontend/README.md](atomizer-dashboard/frontend/README.md) - Frontend setup guide
### For Users
- [README.md](README.md) - Project overview and quick start
- [docs/INDEX.md](docs/INDEX.md) - Complete documentation index
- [docs/](docs/) - Additional documentation
---
## Notes
### Architecture Decisions
- **Hook system**: Chose priority-based execution to allow precise control of plugin order
- **Path resolution**: Used marker files instead of environment variables for simplicity
- **Logging**: Two-tier system (detailed trial logs + high-level optimization.log) for different use cases
### Performance Considerations
- Hook execution adds <1s overhead per trial (acceptable for FEA simulations)
- Path resolution caching could improve startup time (future optimization)
- Log file sizes grow linearly with trials (~10KB per trial)
### Future Considerations
- Consider moving to structured logging (JSON) for easier parsing
- May need database for storing hook execution history (currently in-memory)
- Dashboard integration will require WebSocket for real-time log streaming
---
**Last Updated**: 2025-11-21
**Maintained by**: Antoine Polvé (antoine@atomaste.com)
**Repository**: [GitHub - Atomizer](https://github.com/yourusername/Atomizer)
---
## Recent Updates (November 21, 2025)
### Dashboard System Implementation ✅
- **Backend**: FastAPI + WebSocket with real-time file watching complete
- **HTML Dashboard**: Functional dashboard with Chart.js, data export, pruning alerts
- **React Setup**: Complete project configuration, types, hooks, components
- **Documentation**: 5 comprehensive markdown documents covering architecture, implementation, and usage
### Next Immediate Steps
1. Run `npm install` in `atomizer-dashboard/frontend`
2. Create `main.tsx`, `App.tsx`, and `Dashboard.tsx` using provided templates
3. Test React dashboard with live optimization
4. Build Study Configurator page (next major feature)

View File

@@ -1,63 +0,0 @@
# Atomizer Installation Guide
## Step 1: Install Miniconda (Recommended)
1. Download Miniconda from: https://docs.conda.io/en/latest/miniconda.html
- Choose: **Miniconda3 Windows 64-bit**
2. Run the installer:
- Check "Add Miniconda3 to my PATH environment variable"
- Check "Register Miniconda3 as my default Python"
3. Restart your terminal/VSCode after installation
## Step 2: Create Atomizer Environment
Open **Anaconda Prompt** (or any terminal after restart) and run:
```bash
cd C:\Users\Antoine\Atomizer
conda env create -f environment.yml
conda activate atomizer
```
## Step 3: Install PyTorch with GPU Support (Optional but Recommended)
If you have an NVIDIA GPU:
```bash
conda activate atomizer
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install torch-geometric
```
## Step 4: Verify Installation
```bash
conda activate atomizer
python -c "import torch; import optuna; import pyNastran; print('All imports OK!')"
python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')"
```
## Step 5: Train Neural Network
```bash
conda activate atomizer
cd C:\Users\Antoine\Atomizer\atomizer-field
python train_parametric.py --train_dir ../atomizer_field_training_data/bracket_stiffness_optimization_atomizerfield --epochs 100 --output_dir runs/bracket_model
```
## Quick Commands Reference
```bash
# Activate environment (do this every time you open a new terminal)
conda activate atomizer
# Train neural network
cd C:\Users\Antoine\Atomizer\atomizer-field
python train_parametric.py --train_dir ../atomizer_field_training_data/bracket_stiffness_optimization_atomizerfield --epochs 100
# Run optimization with neural acceleration
cd C:\Users\Antoine\Atomizer\studies\bracket_stiffness_optimization_atomizerfield
python run_optimization.py --run --trials 100 --enable-nn
```

111
PROJECT_STATUS.md Normal file
View File

@@ -0,0 +1,111 @@
# PROJECT_STATUS.md
> **Bridge document for Mario (Clawdbot) ↔ Claude Code coordination**
>
> Both AIs should read this at session start. Update when priorities change.
*Last updated: 2026-01-27 by Mario*
---
## Current Focus
**Phase**: Foundation (Phase 1)
**Sprint**: 2026-01-27 to 2026-02-03
### This Week's Priorities
**Now (Sprint 1.5): Draft + Publish (S2)**
1. 🔴 Implement DraftManager (local autosave draft per study)
2. 🔴 Add Draft vs Published banner + Publish button
3. 🔴 Restore/discard draft prompt on load
**Next (Sprint 2): Create Wizard v1 shell**
4. 🟡 /create route + stepper
5. 🟡 Files step (dependency tree + _i.prt warnings)
6. 🟡 Introspection step (expressions + DV selection)
### Completed recently
- Spec/Canvas wiring sync foundation (converters, connect/delete wiring, output picker, panel rewiring, edge projection)
### Blocked
- None (but local npm install on this server fails due to peer deps; run builds/tests on Windows dev env)
---
## Active Decisions
| Decision | Summary | Date |
|----------|---------|------|
| Full Partnership | Mario = PM, reviewer, architect. Antoine = developer, NX. | 2026-01-27 |
| Dashboard on Windows | Keep simple for now, hybrid architecture later | 2026-01-27 |
| Adopt Clawdbot Patterns | MEMORY.md, QUICK_REF.md, simplified CLAUDE.md | 2026-01-27 |
---
## For Claude Code
When starting a session:
1. ✅ Read CLAUDE.md (system instructions)
2. ✅ Read PROJECT_STATUS.md (this file — current priorities)
3. ✅ Read `knowledge_base/lac/session_insights/failure.jsonl` (critical lessons)
4. 🔲 After session: Commit any new LAC insights to Git
### LAC Commit Protocol (NEW)
After each significant session, commit LAC changes:
```bash
cd Atomizer
git add knowledge_base/lac/
git commit -m "lac: Session insights from YYYY-MM-DD"
git push origin main && git push github main
```
This ensures Mario can see what Claude Code learned.
---
## For Mario (Clawdbot)
When checking on Atomizer:
1. Pull latest from Gitea: `cd /home/papa/repos/Atomizer && git pull`
2. Check `knowledge_base/lac/session_insights/` for new learnings
3. Update tracking files in `/home/papa/clawd/memory/atomizer/`
4. Update this file if priorities change
### Heartbeat Check (Add to HEARTBEAT.md)
```markdown
### Atomizer Check (weekly)
- git pull Atomizer repo
- Check for new LAC insights
- Review recent commits
- Update roadmap if needed
```
---
## Recent Activity
| Date | Activity | Who |
|------|----------|-----|
| 2026-01-27 | Created master plan in PKM | Mario |
| 2026-01-27 | Created tracking files | Mario |
| 2026-01-27 | ACKed Atomizer project | Mario |
| 2026-01-27 | Canvas V3.1 improvements | Claude Code (prior) |
---
## Links
- **Master Plan**: `/home/papa/obsidian-vault/2-Projects/Atomizer-AtomasteAI/Development/ATOMIZER-NEXT-LEVEL-MASTERPLAN.md`
- **Mario's Tracking**: `/home/papa/clawd/memory/atomizer/`
- **LAC Insights**: `knowledge_base/lac/session_insights/`
- **Full Roadmap**: See Master Plan in PKM
---
*This file lives in the repo. Both AIs can read it. Only update when priorities change.*

View File

@@ -13,7 +13,19 @@ import sys
# Add parent directory to path to import optimization_engine
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
from api.routes import optimization, claude, terminal, insights, context, files, nx
from api.routes import (
optimization,
claude,
terminal,
insights,
context,
files,
nx,
claude_code,
spec,
devloop,
intake,
)
from api.websocket import optimization_stream
@@ -23,6 +35,7 @@ async def lifespan(app: FastAPI):
"""Manage application lifespan - start/stop session manager"""
# Startup
from api.routes.claude import get_session_manager
manager = get_session_manager()
await manager.start()
print("Session manager started")
@@ -60,6 +73,12 @@ app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
app.include_router(context.router, prefix="/api/context", tags=["context"])
app.include_router(files.router, prefix="/api/files", tags=["files"])
app.include_router(nx.router, prefix="/api/nx", tags=["nx"])
app.include_router(claude_code.router, prefix="/api", tags=["claude-code"])
app.include_router(spec.router, prefix="/api", tags=["spec"])
app.include_router(spec.validate_router, prefix="/api", tags=["spec"])
app.include_router(devloop.router, prefix="/api", tags=["devloop"])
app.include_router(intake.router, prefix="/api", tags=["intake"])
@app.get("/")
async def root():
@@ -67,11 +86,13 @@ async def root():
dashboard_path = Path(__file__).parent.parent.parent / "dashboard-enhanced.html"
return FileResponse(dashboard_path)
@app.get("/health")
async def health_check():
"""Health check endpoint with database status"""
try:
from api.services.conversation_store import ConversationStore
store = ConversationStore()
# Test database by creating/getting a health check session
store.get_session("health_check")
@@ -84,12 +105,8 @@ async def health_check():
"database": db_status,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, log_level="info")

View File

@@ -187,7 +187,15 @@ async def session_websocket(websocket: WebSocket, session_id: str):
continue
# Get canvas state from message or use stored state
canvas_state = data.get("canvas_state") or current_canvas_state
msg_canvas = data.get("canvas_state")
canvas_state = msg_canvas if msg_canvas is not None else current_canvas_state
# Debug logging
if canvas_state:
node_count = len(canvas_state.get("nodes", []))
print(f"[Claude WS] Sending message with canvas state: {node_count} nodes")
else:
print("[Claude WS] Sending message WITHOUT canvas state")
async for chunk in manager.send_message(
session_id,
@@ -401,6 +409,175 @@ async def websocket_chat(websocket: WebSocket):
pass
# ========== POWER MODE: Direct API with Write Tools ==========
@router.websocket("/sessions/{session_id}/ws/power")
async def power_mode_websocket(websocket: WebSocket, session_id: str):
"""
WebSocket for power mode chat using direct Anthropic API with write tools.
Unlike the regular /ws endpoint which uses Claude CLI + MCP,
this uses AtomizerClaudeAgent directly with built-in write tools.
This allows immediate modifications without permission prompts.
Message formats (client -> server):
{"type": "message", "content": "user message"}
{"type": "set_study", "study_id": "study_name"}
{"type": "ping"}
Message formats (server -> client):
{"type": "text", "content": "..."}
{"type": "tool_call", "tool": "...", "input": {...}}
{"type": "tool_result", "result": "..."}
{"type": "done", "tool_calls": [...]}
{"type": "error", "message": "..."}
{"type": "spec_modified", "changes": [...]}
{"type": "pong"}
"""
await websocket.accept()
manager = get_session_manager()
session = manager.get_session(session_id)
if not session:
await websocket.send_json({"type": "error", "message": "Session not found"})
await websocket.close()
return
# Import AtomizerClaudeAgent for direct API access
from api.services.claude_agent import AtomizerClaudeAgent
# Create agent with study context
agent = AtomizerClaudeAgent(study_id=session.study_id)
conversation_history: List[Dict[str, Any]] = []
# Load initial spec and set canvas state so Claude sees current canvas
initial_spec = agent.load_current_spec()
if initial_spec:
# Send initial spec to frontend
await websocket.send_json({
"type": "spec_updated",
"spec": initial_spec,
"reason": "initial_load"
})
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "message":
content = data.get("content", "")
if not content:
continue
try:
# Use streaming API with tool support for real-time response
last_tool_calls = []
async for event in agent.chat_stream_with_tools(content, conversation_history):
event_type = event.get("type")
if event_type == "text":
# Stream text tokens to frontend immediately
await websocket.send_json({
"type": "text",
"content": event.get("content", ""),
})
elif event_type == "tool_call":
# Tool is being called
tool_info = event.get("tool", {})
await websocket.send_json({
"type": "tool_call",
"tool": tool_info,
})
elif event_type == "tool_result":
# Tool finished executing
tool_name = event.get("tool", "")
await websocket.send_json({
"type": "tool_result",
"tool": tool_name,
"result": event.get("result", ""),
})
# If it was a write tool, send full updated spec
if tool_name in ["add_design_variable", "add_extractor",
"add_objective", "add_constraint",
"update_spec_field", "remove_node",
"create_study"]:
# Load updated spec and update agent's canvas state
updated_spec = agent.load_current_spec()
if updated_spec:
await websocket.send_json({
"type": "spec_updated",
"tool": tool_name,
"spec": updated_spec, # Full spec for direct canvas update
})
elif event_type == "done":
# Streaming complete
last_tool_calls = event.get("tool_calls", [])
await websocket.send_json({
"type": "done",
"tool_calls": last_tool_calls,
})
# Update conversation history for next message
# Note: For proper history tracking, we'd need to store messages properly
# For now, we append the user message and response
conversation_history.append({"role": "user", "content": content})
conversation_history.append({"role": "assistant", "content": event.get("response", "")})
except Exception as e:
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "error",
"message": str(e),
})
elif data.get("type") == "canvas_edit":
# User made a manual edit to the canvas - update Claude's context
spec = data.get("spec")
if spec:
agent.set_canvas_state(spec)
await websocket.send_json({
"type": "canvas_edit_received",
"acknowledged": True
})
elif data.get("type") == "set_study":
study_id = data.get("study_id")
if study_id:
await manager.set_study_context(session_id, study_id)
# Recreate agent with new study context
agent = AtomizerClaudeAgent(study_id=study_id)
conversation_history = [] # Clear history on study change
# Load spec for new study
new_spec = agent.load_current_spec()
await websocket.send_json({
"type": "context_updated",
"study_id": study_id,
})
if new_spec:
await websocket.send_json({
"type": "spec_updated",
"spec": new_spec,
"reason": "study_change"
})
elif data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
try:
await websocket.send_json({"type": "error", "message": str(e)})
except:
pass
@router.get("/suggestions")
async def get_chat_suggestions(study_id: Optional[str] = None):
"""

View File

@@ -83,23 +83,49 @@ async def generate_extractor_code(request: ExtractorGenerationRequest):
# Build focused system prompt for extractor generation
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
The function MUST:
1. Have signature: def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict
2. Return a dict with extracted values (e.g., {"max_stress": 150.5, "mass": 2.3})
3. Use pyNastran.op2.op2.OP2 for reading OP2 results
4. Handle missing data gracefully with try/except blocks
IMPORTANT: Choose the appropriate function signature based on what data is needed:
Available imports (already available, just use them):
- from pyNastran.op2.op2 import OP2
- import numpy as np
- from pathlib import Path
## Option 1: FEA Results (OP2) - Use for stresses, displacements, frequencies, forces
```python
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(op2_path)
# Access: op2.displacements[subcase_id], op2.cquad4_stress[subcase_id], etc.
return {"max_stress": value}
```
Common patterns:
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z components)
## Option 2: Expression/Computed Values (no FEA needed) - Use for dimensions, volumes, derived values
```python
def extract(trial_dir: str, config: dict, context: dict) -> dict:
import json
from pathlib import Path
# Read mass properties (if available from model introspection)
mass_file = Path(trial_dir) / "mass_properties.json"
if mass_file.exists():
with open(mass_file) as f:
props = json.load(f)
mass = props.get("mass_kg", 0)
# Or use config values directly (e.g., expression values)
length_mm = config.get("length_expression", 100)
# context has results from other extractors
other_value = context.get("other_extractor_output", 0)
return {"computed_value": length_mm * 2}
```
Available imports: pyNastran.op2.op2.OP2, numpy, pathlib.Path, json
Common OP2 patterns:
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z)
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
- Eigenvalues: op2.eigenvalues[subcase_id]
- Mass: op2.grid_point_weight (if available)
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations outside the code block."""
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations."""
# Build user prompt with context
user_prompt = f"Generate a custom extractor that: {request.prompt}"

View File

@@ -0,0 +1,416 @@
"""
DevLoop API Endpoints - Closed-loop development orchestration.
Provides REST API and WebSocket for:
- Starting/stopping development cycles
- Monitoring progress
- Executing single phases
- Viewing history and learnings
"""
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional
import asyncio
import json
import sys
from pathlib import Path
from datetime import datetime
# Add project root to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
router = APIRouter(prefix="/devloop", tags=["devloop"])
# Global orchestrator instance
_orchestrator = None
_active_cycle = None
_websocket_clients: List[WebSocket] = []
def get_orchestrator():
"""Get or create the DevLoop orchestrator."""
global _orchestrator
if _orchestrator is None:
from optimization_engine.devloop import DevLoopOrchestrator
_orchestrator = DevLoopOrchestrator(
{
"dashboard_url": "http://localhost:8000",
"websocket_url": "ws://localhost:8000",
"studies_dir": str(Path(__file__).parent.parent.parent.parent.parent / "studies"),
"learning_enabled": True,
}
)
# Subscribe to state updates
_orchestrator.subscribe(_broadcast_state_update)
return _orchestrator
def _broadcast_state_update(state):
"""Broadcast state updates to all WebSocket clients."""
asyncio.create_task(
_send_to_all_clients(
{
"type": "state_update",
"state": {
"phase": state.phase.value,
"iteration": state.iteration,
"current_task": state.current_task,
"last_update": state.last_update,
},
}
)
)
async def _send_to_all_clients(message: Dict):
"""Send message to all connected WebSocket clients."""
disconnected = []
for client in _websocket_clients:
try:
await client.send_json(message)
except Exception:
disconnected.append(client)
# Clean up disconnected clients
for client in disconnected:
if client in _websocket_clients:
_websocket_clients.remove(client)
# ============================================================================
# Request/Response Models
# ============================================================================
class StartCycleRequest(BaseModel):
"""Request to start a development cycle."""
objective: str = Field(..., description="What to achieve")
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
max_iterations: Optional[int] = Field(default=10, description="Maximum iterations")
class StepRequest(BaseModel):
"""Request to execute a single step."""
phase: str = Field(..., description="Phase to execute: plan, implement, test, analyze")
data: Optional[Dict[str, Any]] = Field(default=None, description="Phase-specific data")
class CycleStatusResponse(BaseModel):
"""Response with cycle status."""
active: bool
phase: str
iteration: int
current_task: Optional[str]
last_update: str
# ============================================================================
# REST Endpoints
# ============================================================================
@router.get("/status")
async def get_status() -> CycleStatusResponse:
"""Get current DevLoop status."""
orchestrator = get_orchestrator()
state = orchestrator.get_state()
return CycleStatusResponse(
active=state["phase"] != "idle",
phase=state["phase"],
iteration=state["iteration"],
current_task=state.get("current_task"),
last_update=state["last_update"],
)
@router.post("/start")
async def start_cycle(request: StartCycleRequest, background_tasks: BackgroundTasks):
"""
Start a new development cycle.
The cycle runs in the background and broadcasts progress via WebSocket.
"""
global _active_cycle
orchestrator = get_orchestrator()
# Check if already running
if orchestrator.state.phase.value != "idle":
raise HTTPException(status_code=409, detail="A development cycle is already running")
# Start cycle in background
async def run_cycle():
global _active_cycle
try:
result = await orchestrator.run_development_cycle(
objective=request.objective,
context=request.context,
max_iterations=request.max_iterations,
)
_active_cycle = result
# Broadcast completion
await _send_to_all_clients(
{
"type": "cycle_complete",
"result": {
"objective": result.objective,
"status": result.status,
"iterations": len(result.iterations),
"duration_seconds": result.total_duration_seconds,
},
}
)
except Exception as e:
await _send_to_all_clients({"type": "cycle_error", "error": str(e)})
background_tasks.add_task(run_cycle)
return {
"message": "Development cycle started",
"objective": request.objective,
}
@router.post("/stop")
async def stop_cycle():
"""Stop the current development cycle."""
orchestrator = get_orchestrator()
if orchestrator.state.phase.value == "idle":
raise HTTPException(status_code=400, detail="No active cycle to stop")
# Set state to idle (will stop at next phase boundary)
orchestrator._update_state(phase=orchestrator.state.phase.__class__.IDLE, task="Stopping...")
return {"message": "Cycle stop requested"}
@router.post("/step")
async def execute_step(request: StepRequest):
"""
Execute a single phase step.
Useful for manual control or debugging.
"""
orchestrator = get_orchestrator()
if request.phase == "plan":
objective = request.data.get("objective", "") if request.data else ""
context = request.data.get("context") if request.data else None
result = await orchestrator.step_plan(objective, context)
elif request.phase == "implement":
plan = request.data if request.data else {}
result = await orchestrator.step_implement(plan)
elif request.phase == "test":
scenarios = request.data.get("scenarios", []) if request.data else []
result = await orchestrator.step_test(scenarios)
elif request.phase == "analyze":
test_results = request.data if request.data else {}
result = await orchestrator.step_analyze(test_results)
else:
raise HTTPException(
status_code=400,
detail=f"Unknown phase: {request.phase}. Valid: plan, implement, test, analyze",
)
return {"phase": request.phase, "result": result}
@router.get("/history")
async def get_history():
"""Get history of past development cycles."""
orchestrator = get_orchestrator()
return orchestrator.export_history()
@router.get("/last-cycle")
async def get_last_cycle():
"""Get details of the most recent cycle."""
global _active_cycle
if _active_cycle is None:
raise HTTPException(status_code=404, detail="No cycle has been run yet")
return {
"objective": _active_cycle.objective,
"status": _active_cycle.status,
"start_time": _active_cycle.start_time,
"end_time": _active_cycle.end_time,
"iterations": [
{
"iteration": it.iteration,
"success": it.success,
"duration_seconds": it.duration_seconds,
"has_plan": it.plan is not None,
"has_tests": it.test_results is not None,
"has_fixes": it.fixes is not None,
}
for it in _active_cycle.iterations
],
"total_duration_seconds": _active_cycle.total_duration_seconds,
}
@router.get("/health")
async def health_check():
"""Check DevLoop system health."""
orchestrator = get_orchestrator()
# Check dashboard connection
from optimization_engine.devloop import DashboardTestRunner
runner = DashboardTestRunner()
dashboard_health = await runner.run_health_check()
return {
"devloop": "healthy",
"orchestrator_state": orchestrator.get_state()["phase"],
"dashboard": dashboard_health,
}
# ============================================================================
# WebSocket Endpoint
# ============================================================================
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""
WebSocket endpoint for real-time DevLoop updates.
Messages sent:
- state_update: Phase/iteration changes
- cycle_complete: Cycle finished
- cycle_error: Cycle failed
- test_progress: Individual test results
"""
await websocket.accept()
_websocket_clients.append(websocket)
orchestrator = get_orchestrator()
try:
# Send initial state
await websocket.send_json(
{
"type": "connection_ack",
"state": orchestrator.get_state(),
}
)
# Handle incoming messages
while True:
try:
data = await asyncio.wait_for(websocket.receive_json(), timeout=30.0)
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "get_state":
await websocket.send_json(
{
"type": "state",
"state": orchestrator.get_state(),
}
)
elif msg_type == "start_cycle":
# Allow starting cycle via WebSocket
objective = data.get("objective", "")
context = data.get("context")
asyncio.create_task(orchestrator.run_development_cycle(objective, context))
await websocket.send_json(
{
"type": "cycle_started",
"objective": objective,
}
)
except asyncio.TimeoutError:
# Send heartbeat
await websocket.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
if websocket in _websocket_clients:
_websocket_clients.remove(websocket)
# ============================================================================
# Convenience Endpoints for Common Tasks
# ============================================================================
@router.post("/create-study")
async def create_study_cycle(
study_name: str,
problem_statement: Optional[str] = None,
background_tasks: BackgroundTasks = None,
):
"""
Convenience endpoint to start a study creation cycle.
This is a common workflow that combines planning, implementation, and testing.
"""
orchestrator = get_orchestrator()
context = {
"study_name": study_name,
"task_type": "create_study",
}
if problem_statement:
context["problem_statement"] = problem_statement
# Start the cycle
async def run_cycle():
result = await orchestrator.run_development_cycle(
objective=f"Create optimization study: {study_name}",
context=context,
)
return result
if background_tasks:
background_tasks.add_task(run_cycle)
return {"message": f"Study creation cycle started for '{study_name}'"}
else:
result = await run_cycle()
return {
"message": f"Study '{study_name}' creation completed",
"status": result.status,
"iterations": len(result.iterations),
}
@router.post("/run-tests")
async def run_tests(scenarios: List[Dict[str, Any]]):
"""
Run a set of test scenarios directly.
Useful for testing specific features without a full cycle.
"""
from optimization_engine.devloop import DashboardTestRunner
runner = DashboardTestRunner()
results = await runner.run_test_suite(scenarios)
return results

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
"""
AtomizerSpec v2.0 API Endpoints
REST API for managing AtomizerSpec configurations.
All spec modifications flow through these endpoints.
Endpoints:
- GET /studies/{study_id}/spec - Get full spec
- PUT /studies/{study_id}/spec - Replace entire spec
- PATCH /studies/{study_id}/spec - Partial update
- POST /studies/{study_id}/spec/validate - Validate spec
- POST /studies/{study_id}/spec/nodes - Add node
- PATCH /studies/{study_id}/spec/nodes/{node_id} - Update node
- DELETE /studies/{study_id}/spec/nodes/{node_id} - Delete node
- POST /studies/{study_id}/spec/custom-functions - Add custom extractor
- WebSocket /studies/{study_id}/spec/sync - Real-time sync
"""
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import json
import sys
import asyncio
# Add project root to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
from api.services.spec_manager import (
SpecManager,
SpecManagerError,
SpecNotFoundError,
SpecConflictError,
get_spec_manager,
)
from optimization_engine.config.spec_models import (
AtomizerSpec,
ValidationReport,
)
from optimization_engine.config.spec_validator import SpecValidationError
router = APIRouter(prefix="/studies/{study_id:path}/spec", tags=["spec"])
# Base studies directory
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
# ============================================================================
# Request/Response Models
# ============================================================================
class SpecPatchRequest(BaseModel):
"""Request for patching a spec field."""
path: str = Field(..., description="JSONPath to the field (e.g., 'objectives[0].weight')")
value: Any = Field(..., description="New value")
modified_by: str = Field(default="api", description="Who is making the change")
class NodeAddRequest(BaseModel):
"""Request for adding a node."""
type: str = Field(..., description="Node type: designVar, extractor, objective, constraint")
data: Dict[str, Any] = Field(..., description="Node data")
modified_by: str = Field(default="canvas", description="Who is making the change")
class NodeUpdateRequest(BaseModel):
"""Request for updating a node."""
updates: Dict[str, Any] = Field(..., description="Fields to update")
modified_by: str = Field(default="canvas", description="Who is making the change")
class CustomFunctionRequest(BaseModel):
"""Request for adding a custom extractor function."""
name: str = Field(..., description="Function name")
code: str = Field(..., description="Python source code")
outputs: List[str] = Field(..., description="Output names")
description: Optional[str] = Field(default=None, description="Human-readable description")
modified_by: str = Field(default="claude", description="Who is making the change")
class ExtractorValidationRequest(BaseModel):
"""Request for validating custom extractor code."""
function_name: str = Field(default="extract", description="Expected function name")
source: str = Field(..., description="Python source code to validate")
class SpecUpdateResponse(BaseModel):
"""Response for spec modification operations."""
success: bool
hash: str
modified: str
modified_by: str
class NodeAddResponse(BaseModel):
"""Response for node add operation."""
success: bool
node_id: str
message: str
class ValidationResponse(BaseModel):
"""Response for validation endpoint."""
valid: bool
errors: List[Dict[str, Any]]
warnings: List[Dict[str, Any]]
summary: Dict[str, int]
# ============================================================================
# Helper Functions
# ============================================================================
def resolve_study_path(study_id: str) -> Path:
"""Find study folder by scanning all topic directories.
Supports both formats:
- "study_name" - Will scan topic folders to find it
- "Topic/study_name" - Direct nested path (e.g., "M1_Mirror/m1_mirror_v1")
"""
# Handle nested paths (e.g., "M1_Mirror/m1_mirror_cost_reduction_lateral")
if "/" in study_id:
nested_path = STUDIES_DIR / study_id.replace("/", "\\") # Handle Windows paths
if nested_path.exists() and nested_path.is_dir():
return nested_path
# Also try with forward slashes (Path handles both)
nested_path = STUDIES_DIR / study_id
if nested_path.exists() and nested_path.is_dir():
return nested_path
# Direct path (flat structure)
direct_path = STUDIES_DIR / study_id
if direct_path.exists() and direct_path.is_dir():
return direct_path
# Scan topic folders (nested structure)
for topic_dir in STUDIES_DIR.iterdir():
if topic_dir.is_dir() and not topic_dir.name.startswith('.'):
study_dir = topic_dir / study_id
if study_dir.exists() and study_dir.is_dir():
return study_dir
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
def get_manager(study_id: str) -> SpecManager:
"""Get SpecManager for a study."""
study_path = resolve_study_path(study_id)
return get_spec_manager(study_path)
# ============================================================================
# REST Endpoints
# ============================================================================
@router.get("", response_model=None)
async def get_spec(study_id: str):
"""
Get the full AtomizerSpec for a study.
Returns the complete spec JSON with all design variables, extractors,
objectives, constraints, and canvas state.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(
status_code=404,
detail=f"No AtomizerSpec found for study '{study_id}'. Use migration or create new spec."
)
try:
spec = manager.load()
return spec.model_dump(mode='json')
except SpecValidationError as e:
# Return spec even if invalid, but include validation info
raw = manager.load_raw()
return JSONResponse(
status_code=200,
content={
**raw,
"_validation_error": str(e)
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/raw")
async def get_spec_raw(study_id: str):
"""
Get the raw spec JSON without validation.
Useful for debugging or when spec is invalid.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
return manager.load_raw()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/hash")
async def get_spec_hash(study_id: str):
"""Get the current spec hash for conflict detection."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
return {"hash": manager.get_hash()}
@router.put("", response_model=SpecUpdateResponse)
async def replace_spec(
study_id: str,
spec: Dict[str, Any],
modified_by: str = Query(default="api"),
expected_hash: Optional[str] = Query(default=None)
):
"""
Replace the entire spec.
Validates the new spec before saving. Optionally check for conflicts
using expected_hash parameter.
"""
manager = get_manager(study_id)
try:
new_hash = manager.save(spec, modified_by=modified_by, expected_hash=expected_hash)
reloaded = manager.load()
return SpecUpdateResponse(
success=True,
hash=new_hash,
modified=reloaded.meta.modified or "",
modified_by=modified_by
)
except SpecConflictError as e:
raise HTTPException(
status_code=409,
detail={
"message": str(e),
"current_hash": e.current_hash
}
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("", response_model=SpecUpdateResponse)
async def patch_spec(study_id: str, request: SpecPatchRequest):
"""
Partial update to spec using JSONPath.
Example paths:
- "objectives[0].weight" - Update objective weight
- "design_variables[1].bounds.max" - Update DV bound
- "meta.description" - Update description
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
spec = manager.patch(request.path, request.value, modified_by=request.modified_by)
return SpecUpdateResponse(
success=True,
hash=manager.get_hash(),
modified=spec.meta.modified or "",
modified_by=request.modified_by
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/validate", response_model=ValidationResponse)
async def validate_spec(study_id: str):
"""
Validate the spec and return detailed report.
Returns errors, warnings, and summary of the spec contents.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
report = manager.validate_and_report()
return ValidationResponse(
valid=report.valid,
errors=[e.model_dump() for e in report.errors],
warnings=[w.model_dump() for w in report.warnings],
summary=report.summary.model_dump()
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Node CRUD Endpoints
# ============================================================================
@router.post("/nodes", response_model=NodeAddResponse)
async def add_node(study_id: str, request: NodeAddRequest):
"""
Add a new node to the spec.
Supported types: designVar, extractor, objective, constraint
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
valid_types = ["designVar", "extractor", "objective", "constraint"]
if request.type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Invalid node type '{request.type}'. Valid: {valid_types}"
)
try:
node_id = manager.add_node(request.type, request.data, modified_by=request.modified_by)
return NodeAddResponse(
success=True,
node_id=node_id,
message=f"Added {request.type} node: {node_id}"
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/nodes/{node_id}")
async def update_node(study_id: str, node_id: str, request: NodeUpdateRequest):
"""Update an existing node's properties."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.update_node(node_id, request.updates, modified_by=request.modified_by)
return {"success": True, "message": f"Updated node {node_id}"}
except SpecManagerError as e:
raise HTTPException(status_code=404, detail=str(e))
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/nodes/{node_id}")
async def delete_node(
study_id: str,
node_id: str,
modified_by: str = Query(default="canvas")
):
"""
Delete a node and all edges referencing it.
Use with caution - this will also remove any objectives or constraints
that reference a deleted extractor.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.remove_node(node_id, modified_by=modified_by)
return {"success": True, "message": f"Removed node {node_id}"}
except SpecManagerError as e:
raise HTTPException(status_code=404, detail=str(e))
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Custom Function Endpoint
# ============================================================================
@router.post("/custom-functions", response_model=NodeAddResponse)
async def add_custom_function(study_id: str, request: CustomFunctionRequest):
"""
Add a custom Python function as an extractor.
The function will be available in the optimization workflow.
Claude can use this to add new physics extraction logic.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
extractor_id = manager.add_custom_function(
name=request.name,
code=request.code,
outputs=request.outputs,
description=request.description,
modified_by=request.modified_by
)
return NodeAddResponse(
success=True,
node_id=extractor_id,
message=f"Added custom extractor: {request.name}"
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Separate router for non-study-specific endpoints
validate_router = APIRouter(prefix="/spec", tags=["spec"])
@validate_router.post("/validate-extractor")
async def validate_custom_extractor(request: ExtractorValidationRequest):
"""
Validate custom extractor Python code.
Checks syntax, security patterns, and function signature.
Does not require a study - can be used before adding to spec.
"""
try:
from optimization_engine.extractors.custom_extractor_loader import (
validate_extractor_code,
ExtractorSecurityError,
)
try:
is_valid, errors = validate_extractor_code(request.source, request.function_name)
return {
"valid": is_valid,
"errors": errors
}
except ExtractorSecurityError as e:
return {
"valid": False,
"errors": [str(e)]
}
except ImportError as e:
raise HTTPException(
status_code=500,
detail=f"Custom extractor loader not available: {e}"
)
# ============================================================================
# Edge Endpoints
# ============================================================================
@router.post("/edges")
async def add_edge(
study_id: str,
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
modified_by: str = Query(default="canvas")
):
"""Add a canvas edge between two nodes."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.add_edge(source, target, modified_by=modified_by)
return {"success": True, "message": f"Added edge {source} -> {target}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/edges")
async def delete_edge(
study_id: str,
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
modified_by: str = Query(default="canvas")
):
"""Remove a canvas edge."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.remove_edge(source, target, modified_by=modified_by)
return {"success": True, "message": f"Removed edge {source} -> {target}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WebSocket Sync Endpoint
# ============================================================================
class WebSocketSubscriber:
"""WebSocket subscriber adapter."""
def __init__(self, websocket: WebSocket):
self.websocket = websocket
async def send_json(self, data: Dict[str, Any]) -> None:
await self.websocket.send_json(data)
@router.websocket("/sync")
async def websocket_sync(websocket: WebSocket, study_id: str):
"""
WebSocket endpoint for real-time spec sync.
Clients receive notifications when spec changes:
- spec_updated: Spec was modified
- node_added: New node added
- node_removed: Node removed
- validation_error: Validation failed
"""
await websocket.accept()
manager = get_manager(study_id)
subscriber = WebSocketSubscriber(websocket)
# Subscribe to updates
manager.subscribe(subscriber)
try:
# Send initial connection ack
await websocket.send_json({
"type": "connection_ack",
"study_id": study_id,
"hash": manager.get_hash() if manager.exists() else None,
"message": "Connected to spec sync"
})
# Keep connection alive and handle client messages
while True:
try:
data = await asyncio.wait_for(
websocket.receive_json(),
timeout=30.0 # Heartbeat interval
)
# Handle client messages
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "patch_node":
# Client requests node update
try:
manager.update_node(
data["node_id"],
data.get("data", {}),
modified_by=data.get("modified_by", "canvas")
)
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
elif msg_type == "update_position":
# Client updates node position
try:
manager.update_node_position(
data["node_id"],
data["position"],
modified_by=data.get("modified_by", "canvas")
)
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
except asyncio.TimeoutError:
# Send heartbeat
await websocket.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
manager.unsubscribe(subscriber)
# ============================================================================
# Create/Initialize Spec
# ============================================================================
@router.post("/create")
async def create_spec(
study_id: str,
spec: Dict[str, Any],
modified_by: str = Query(default="api")
):
"""
Create a new spec for a study.
Use this when migrating from old config or creating a new study.
Will fail if spec already exists (use PUT to replace).
"""
manager = get_manager(study_id)
if manager.exists():
raise HTTPException(
status_code=409,
detail=f"Spec already exists for '{study_id}'. Use PUT to replace."
)
try:
# Ensure meta fields are set
if "meta" not in spec:
spec["meta"] = {}
spec["meta"]["created_by"] = modified_by
new_hash = manager.save(spec, modified_by=modified_by)
return {
"success": True,
"hash": new_hash,
"message": f"Created spec for {study_id}"
}
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,5 +3,13 @@ Atomizer Dashboard Services
"""
from .claude_agent import AtomizerClaudeAgent
from .spec_manager import SpecManager, SpecManagerError, SpecNotFoundError, SpecConflictError, get_spec_manager
__all__ = ['AtomizerClaudeAgent']
__all__ = [
'AtomizerClaudeAgent',
'SpecManager',
'SpecManagerError',
'SpecNotFoundError',
'SpecConflictError',
'get_spec_manager',
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,396 @@
"""
Claude README Generator Service
Generates intelligent README.md files for optimization studies
using Claude Code CLI (not API) with study context from AtomizerSpec.
"""
import asyncio
import json
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
# Base directory
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
# Load skill prompt
SKILL_PATH = ATOMIZER_ROOT / ".claude" / "skills" / "modules" / "study-readme-generator.md"
def load_skill_prompt() -> str:
"""Load the README generator skill prompt."""
if SKILL_PATH.exists():
return SKILL_PATH.read_text(encoding="utf-8")
return ""
class ClaudeReadmeGenerator:
"""Generate README.md files using Claude Code CLI."""
def __init__(self):
self.skill_prompt = load_skill_prompt()
def generate_readme(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]] = None,
topic: Optional[str] = None,
) -> str:
"""
Generate a README.md for a study using Claude Code CLI.
Args:
study_name: Name of the study
spec: Full AtomizerSpec v2.0 dict
context_files: Optional dict of {filename: content} for context
topic: Optional topic folder name
Returns:
Generated README.md content
"""
# Build context for Claude
context = self._build_context(study_name, spec, context_files, topic)
# Build the prompt
prompt = self._build_prompt(context)
try:
# Run Claude Code CLI synchronously
result = self._run_claude_cli(prompt)
# Extract markdown content from response
readme_content = self._extract_markdown(result)
if readme_content:
return readme_content
# If no markdown found, return the whole response
return result if result else self._generate_fallback_readme(study_name, spec)
except Exception as e:
print(f"Claude CLI error: {e}")
return self._generate_fallback_readme(study_name, spec)
async def generate_readme_async(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]] = None,
topic: Optional[str] = None,
) -> str:
"""Async version of generate_readme."""
# Run in thread pool to not block
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, lambda: self.generate_readme(study_name, spec, context_files, topic)
)
def _run_claude_cli(self, prompt: str) -> str:
"""Run Claude Code CLI and get response."""
try:
# Use claude CLI with --print flag for non-interactive output
result = subprocess.run(
["claude", "--print", prompt],
capture_output=True,
text=True,
timeout=120, # 2 minute timeout
cwd=str(ATOMIZER_ROOT),
)
if result.returncode != 0:
error_msg = result.stderr or "Unknown error"
raise Exception(f"Claude CLI error: {error_msg}")
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise Exception("Request timed out")
except FileNotFoundError:
raise Exception("Claude CLI not found. Make sure 'claude' is in PATH.")
def _build_context(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]],
topic: Optional[str],
) -> Dict[str, Any]:
"""Build the context object for Claude."""
meta = spec.get("meta", {})
model = spec.get("model", {})
introspection = model.get("introspection", {}) or {}
context = {
"study_name": study_name,
"topic": topic or meta.get("topic", "Other"),
"description": meta.get("description", ""),
"created": meta.get("created", datetime.now().isoformat()),
"status": meta.get("status", "draft"),
"design_variables": spec.get("design_variables", []),
"extractors": spec.get("extractors", []),
"objectives": spec.get("objectives", []),
"constraints": spec.get("constraints", []),
"optimization": spec.get("optimization", {}),
"introspection": {
"mass_kg": introspection.get("mass_kg"),
"volume_mm3": introspection.get("volume_mm3"),
"solver_type": introspection.get("solver_type"),
"expressions": introspection.get("expressions", []),
"expressions_count": len(introspection.get("expressions", [])),
},
"model_files": {
"sim": model.get("sim", {}).get("path") if model.get("sim") else None,
"prt": model.get("prt", {}).get("path") if model.get("prt") else None,
"fem": model.get("fem", {}).get("path") if model.get("fem") else None,
},
}
# Add context files if provided
if context_files:
context["context_files"] = context_files
return context
def _build_prompt(self, context: Dict[str, Any]) -> str:
"""Build the prompt for Claude CLI."""
# Build context files section if available
context_files_section = ""
if context.get("context_files"):
context_files_section = "\n\n## User-Provided Context Files\n\nIMPORTANT: Use this information to understand the optimization goals, design variables, objectives, and constraints:\n\n"
for filename, content in context.get("context_files", {}).items():
context_files_section += f"### {filename}\n```\n{content}\n```\n\n"
# Remove context_files from JSON dump to avoid duplication
context_for_json = {k: v for k, v in context.items() if k != "context_files"}
prompt = f"""Generate a README.md for this FEA optimization study.
## Study Technical Data
```json
{json.dumps(context_for_json, indent=2, default=str)}
```
{context_files_section}
## Requirements
1. Use the EXACT values from the technical data - no placeholders
2. If context files are provided, extract:
- Design variable bounds (min/max)
- Optimization objectives (minimize/maximize what)
- Constraints (stress limits, etc.)
- Any specific requirements mentioned
3. Format the README with these sections:
- Title (# Study Name)
- Overview (topic, date, status, description from context)
- Engineering Problem (what we're optimizing and why - from context files)
- Model Information (mass, solver, files)
- Design Variables (if context specifies bounds, include them in a table)
- Optimization Objectives (from context files)
- Constraints (from context files)
- Expressions Found (table of discovered expressions, highlight candidates)
- Next Steps (what needs to be configured)
4. Keep it professional and concise
5. Use proper markdown table formatting
6. Include units where applicable
7. For expressions table, show: name, value, units, is_candidate
Generate ONLY the README.md content in markdown format, no explanations:"""
return prompt
def _extract_markdown(self, response: str) -> Optional[str]:
"""Extract markdown content from Claude response."""
if not response:
return None
# If response starts with #, it's already markdown
if response.strip().startswith("#"):
return response.strip()
# Try to find markdown block
if "```markdown" in response:
start = response.find("```markdown") + len("```markdown")
end = response.find("```", start)
if end > start:
return response[start:end].strip()
if "```md" in response:
start = response.find("```md") + len("```md")
end = response.find("```", start)
if end > start:
return response[start:end].strip()
# Look for first # heading
lines = response.split("\n")
for i, line in enumerate(lines):
if line.strip().startswith("# "):
return "\n".join(lines[i:]).strip()
return None
def _generate_fallback_readme(self, study_name: str, spec: Dict[str, Any]) -> str:
"""Generate a basic README if Claude fails."""
meta = spec.get("meta", {})
model = spec.get("model", {})
introspection = model.get("introspection", {}) or {}
dvs = spec.get("design_variables", [])
objs = spec.get("objectives", [])
cons = spec.get("constraints", [])
opt = spec.get("optimization", {})
expressions = introspection.get("expressions", [])
lines = [
f"# {study_name.replace('_', ' ').title()}",
"",
f"**Topic**: {meta.get('topic', 'Other')}",
f"**Created**: {meta.get('created', 'Unknown')[:10] if meta.get('created') else 'Unknown'}",
f"**Status**: {meta.get('status', 'draft')}",
"",
]
if meta.get("description"):
lines.extend([meta["description"], ""])
# Model Information
lines.extend(
[
"## Model Information",
"",
]
)
if introspection.get("mass_kg"):
lines.append(f"- **Mass**: {introspection['mass_kg']:.2f} kg")
sim_path = model.get("sim", {}).get("path") if model.get("sim") else None
if sim_path:
lines.append(f"- **Simulation**: {sim_path}")
lines.append("")
# Expressions Found
if expressions:
lines.extend(
[
"## Expressions Found",
"",
"| Name | Value | Units | Candidate |",
"|------|-------|-------|-----------|",
]
)
for expr in expressions:
is_candidate = "" if expr.get("is_candidate") else ""
value = f"{expr.get('value', '-')}"
units = expr.get("units", "-")
lines.append(f"| {expr.get('name', '-')} | {value} | {units} | {is_candidate} |")
lines.append("")
# Design Variables (if configured)
if dvs:
lines.extend(
[
"## Design Variables",
"",
"| Variable | Expression | Range | Units |",
"|----------|------------|-------|-------|",
]
)
for dv in dvs:
bounds = dv.get("bounds", {})
units = dv.get("units", "-")
lines.append(
f"| {dv.get('name', 'Unknown')} | "
f"{dv.get('expression_name', '-')} | "
f"[{bounds.get('min', '-')}, {bounds.get('max', '-')}] | "
f"{units} |"
)
lines.append("")
# Objectives
if objs:
lines.extend(
[
"## Objectives",
"",
"| Objective | Direction | Weight |",
"|-----------|-----------|--------|",
]
)
for obj in objs:
lines.append(
f"| {obj.get('name', 'Unknown')} | "
f"{obj.get('direction', 'minimize')} | "
f"{obj.get('weight', 1.0)} |"
)
lines.append("")
# Constraints
if cons:
lines.extend(
[
"## Constraints",
"",
"| Constraint | Condition | Threshold |",
"|------------|-----------|-----------|",
]
)
for con in cons:
lines.append(
f"| {con.get('name', 'Unknown')} | "
f"{con.get('operator', '<=')} | "
f"{con.get('threshold', '-')} |"
)
lines.append("")
# Algorithm
algo = opt.get("algorithm", {})
budget = opt.get("budget", {})
lines.extend(
[
"## Methodology",
"",
f"- **Algorithm**: {algo.get('type', 'TPE')}",
f"- **Max Trials**: {budget.get('max_trials', 100)}",
"",
]
)
# Next Steps
lines.extend(
[
"## Next Steps",
"",
]
)
if not dvs:
lines.append("- [ ] Configure design variables from discovered expressions")
if not objs:
lines.append("- [ ] Define optimization objectives")
if not dvs and not objs:
lines.append("- [ ] Open in Canvas Builder to complete configuration")
else:
lines.append("- [ ] Run baseline solve to validate setup")
lines.append("- [ ] Finalize study to move to studies folder")
lines.append("")
return "\n".join(lines)
# Singleton instance
_generator: Optional[ClaudeReadmeGenerator] = None
def get_readme_generator() -> ClaudeReadmeGenerator:
"""Get the singleton README generator instance."""
global _generator
if _generator is None:
_generator = ClaudeReadmeGenerator()
return _generator

View File

@@ -26,6 +26,7 @@ class ContextBuilder:
study_id: Optional[str] = None,
conversation_history: Optional[List[Dict[str, Any]]] = None,
canvas_state: Optional[Dict[str, Any]] = None,
spec_path: Optional[str] = None,
) -> str:
"""
Build full system prompt with context.
@@ -35,6 +36,7 @@ class ContextBuilder:
study_id: Optional study name to provide context for
conversation_history: Optional recent messages for continuity
canvas_state: Optional canvas state (nodes, edges) from the UI
spec_path: Optional path to the atomizer_spec.json file
Returns:
Complete system prompt string
@@ -43,7 +45,11 @@ class ContextBuilder:
# Canvas context takes priority - if user is working on a canvas, include it
if canvas_state:
parts.append(self._canvas_context(canvas_state))
node_count = len(canvas_state.get("nodes", []))
print(f"[ContextBuilder] Including canvas context with {node_count} nodes")
parts.append(self._canvas_context(canvas_state, spec_path))
else:
print("[ContextBuilder] No canvas state provided")
if study_id:
parts.append(self._study_context(study_id))
@@ -53,7 +59,7 @@ class ContextBuilder:
if conversation_history:
parts.append(self._conversation_context(conversation_history))
parts.append(self._mode_instructions(mode))
parts.append(self._mode_instructions(mode, spec_path))
return "\n\n---\n\n".join(parts)
@@ -91,7 +97,117 @@ Important guidelines:
context = f"# Current Study: {study_id}\n\n"
# Load configuration
# Check for AtomizerSpec v2.0 first (preferred)
spec_path = study_dir / "1_setup" / "atomizer_spec.json"
if not spec_path.exists():
spec_path = study_dir / "atomizer_spec.json"
if spec_path.exists():
context += self._spec_context(spec_path)
else:
# Fall back to legacy optimization_config.json
context += self._legacy_config_context(study_dir)
# Check for results
db_path = study_dir / "3_results" / "study.db"
if db_path.exists():
try:
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
).fetchone()[0]
best = conn.execute("""
SELECT MIN(tv.value) FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE'
""").fetchone()[0]
context += f"\n## Results Status\n\n"
context += f"- **Trials completed**: {count}\n"
if best is not None:
context += f"- **Best objective**: {best:.6f}\n"
conn.close()
except Exception:
pass
return context
def _spec_context(self, spec_path: Path) -> str:
"""Build context from AtomizerSpec v2.0 file"""
context = "**Format**: AtomizerSpec v2.0\n\n"
try:
with open(spec_path) as f:
spec = json.load(f)
context += "## Configuration\n\n"
# Design variables
dvs = spec.get("design_variables", [])
if dvs:
context += "**Design Variables:**\n"
for dv in dvs[:10]:
bounds = dv.get("bounds", {})
bound_str = f"[{bounds.get('min', '?')}, {bounds.get('max', '?')}]"
enabled = "" if dv.get("enabled", True) else ""
context += f"- {dv.get('name', 'unnamed')}: {bound_str} {enabled}\n"
if len(dvs) > 10:
context += f"- ... and {len(dvs) - 10} more\n"
# Extractors
extractors = spec.get("extractors", [])
if extractors:
context += "\n**Extractors:**\n"
for ext in extractors:
ext_type = ext.get("type", "unknown")
outputs = ext.get("outputs", [])
output_names = [o.get("name", "?") for o in outputs[:3]]
builtin = "builtin" if ext.get("builtin", True) else "custom"
context += f"- {ext.get('name', 'unnamed')} ({ext_type}, {builtin}): outputs {output_names}\n"
# Objectives
objs = spec.get("objectives", [])
if objs:
context += "\n**Objectives:**\n"
for obj in objs:
direction = obj.get("direction", "minimize")
weight = obj.get("weight", 1.0)
context += f"- {obj.get('name', 'unnamed')} ({direction}, weight={weight})\n"
# Constraints
constraints = spec.get("constraints", [])
if constraints:
context += "\n**Constraints:**\n"
for c in constraints:
op = c.get("operator", "<=")
thresh = c.get("threshold", "?")
context += f"- {c.get('name', 'unnamed')}: {op} {thresh}\n"
# Optimization settings
opt = spec.get("optimization", {})
algo = opt.get("algorithm", {})
budget = opt.get("budget", {})
method = algo.get("type", "TPE")
max_trials = budget.get("max_trials", "not set")
context += f"\n**Optimization**: {method}, max_trials: {max_trials}\n"
# Surrogate
surrogate = opt.get("surrogate", {})
if surrogate.get("enabled"):
sur_type = surrogate.get("type", "gaussian_process")
context += f"**Surrogate**: {sur_type} enabled\n"
except (json.JSONDecodeError, IOError) as e:
context += f"\n*Spec file exists but could not be parsed: {e}*\n"
return context
def _legacy_config_context(self, study_dir: Path) -> str:
"""Build context from legacy optimization_config.json"""
context = "**Format**: Legacy optimization_config.json\n\n"
config_path = study_dir / "1_setup" / "optimization_config.json"
if not config_path.exists():
config_path = study_dir / "optimization_config.json"
@@ -135,30 +251,8 @@ Important guidelines:
except (json.JSONDecodeError, IOError) as e:
context += f"\n*Config file exists but could not be parsed: {e}*\n"
# Check for results
db_path = study_dir / "3_results" / "study.db"
if db_path.exists():
try:
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
).fetchone()[0]
best = conn.execute("""
SELECT MIN(tv.value) FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE'
""").fetchone()[0]
context += f"\n## Results Status\n\n"
context += f"- **Trials completed**: {count}\n"
if best is not None:
context += f"- **Best objective**: {best:.6f}\n"
conn.close()
except Exception:
pass
else:
context += "*No configuration file found.*\n"
return context
@@ -206,7 +300,7 @@ Important guidelines:
return context
def _canvas_context(self, canvas_state: Dict[str, Any]) -> str:
def _canvas_context(self, canvas_state: Dict[str, Any], spec_path: Optional[str] = None) -> str:
"""
Build context from canvas state (nodes and edges).
@@ -225,6 +319,8 @@ Important guidelines:
context += f"**Study Name**: {study_name}\n"
if study_path:
context += f"**Study Path**: {study_path}\n"
if spec_path:
context += f"**Spec File**: `{spec_path}`\n"
context += "\n"
# Group nodes by type
@@ -346,43 +442,100 @@ Important guidelines:
context += f"Total edges: {len(edges)}\n"
context += "Flow: Design Variables → Model → Solver → Extractors → Objectives/Constraints → Algorithm\n\n"
# Canvas modification instructions
context += """## Canvas Modification Tools
When the user asks to modify the canvas (add/remove nodes, change values), use these MCP tools:
- `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint)
- `canvas_update_node` - Update node properties (bounds, weights, names)
- `canvas_remove_node` - Remove a node from the canvas
- `canvas_connect_nodes` - Create an edge between nodes
**Example user requests you can handle:**
- "Add a design variable called hole_diameter with range 5-15 mm" → Use canvas_add_node
- "Change the weight of wfe_40_20 to 8" → Use canvas_update_node
- "Remove the constraint node" → Use canvas_remove_node
- "Connect the new extractor to the objective" → Use canvas_connect_nodes
Always respond with confirmation of changes made to the canvas.
"""
# Instructions will be in _mode_instructions based on spec_path
return context
def _mode_instructions(self, mode: str) -> str:
def _mode_instructions(self, mode: str, spec_path: Optional[str] = None) -> str:
"""Mode-specific instructions"""
if mode == "power":
return """# Power Mode Instructions
instructions = """# Power Mode Instructions
You have **full access** to Atomizer's codebase. You can:
- Edit any file using `edit_file` tool
- Create new files with `create_file` tool
- Create new extractors with `create_extractor` tool
- Run shell commands with `run_shell_command` tool
- Search codebase with `search_codebase` tool
- Commit and push changes
You have **FULL ACCESS** to modify Atomizer studies. **DO NOT ASK FOR PERMISSION** - just do it.
**Use these powers responsibly.** Always explain what you're doing and why.
## CRITICAL: How to Modify the Spec
For routine operations (list, status, run, analyze), use the standard tools.
"""
if spec_path:
instructions += f"""**The spec file is at**: `{spec_path}`
When asked to add/modify/remove design variables, extractors, objectives, or constraints:
1. **Read the spec file first** using the Read tool
2. **Edit the spec file** using the Edit tool to make precise changes
3. **Confirm what you changed** in your response
### AtomizerSpec v2.0 Structure
The spec has these main arrays you can modify:
- `design_variables` - Parameters to optimize
- `extractors` - Physics extraction functions
- `objectives` - What to minimize/maximize
- `constraints` - Limits that must be satisfied
### Example: Add a Design Variable
To add a design variable called "thickness" with bounds [1, 10]:
1. Read the spec: `Read({spec_path})`
2. Find the `"design_variables": [...]` array
3. Add a new entry like:
```json
{{
"id": "dv_thickness",
"name": "thickness",
"expression_name": "thickness",
"type": "continuous",
"bounds": {{"min": 1, "max": 10}},
"baseline": 5,
"units": "mm",
"enabled": true
}}
```
4. Use Edit tool to insert this into the array
### Example: Add an Objective
To add a "minimize mass" objective:
```json
{{
"id": "obj_mass",
"name": "mass",
"direction": "minimize",
"weight": 1.0,
"source": {{
"extractor_id": "ext_mass",
"output_name": "mass"
}}
}}
```
### Example: Add an Extractor
To add a mass extractor:
```json
{{
"id": "ext_mass",
"name": "mass",
"type": "mass",
"builtin": true,
"outputs": [{{"name": "mass", "units": "kg"}}]
}}
```
"""
else:
instructions += """No spec file is currently set. Ask the user which study they want to work with.
"""
instructions += """## IMPORTANT Rules:
- You have --dangerously-skip-permissions enabled
- **ACT IMMEDIATELY** when asked to add/modify/remove things
- Use the **Edit** tool to modify the spec file directly
- Generate unique IDs like `dv_<name>`, `ext_<name>`, `obj_<name>`, `con_<name>`
- Explain what you changed AFTER doing it, not before
- Do NOT say "I need permission" - you already have it
"""
return instructions
else:
return """# User Mode Instructions
@@ -393,20 +546,11 @@ You can help with optimization workflows:
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
**For modifying studies**, the user needs to switch to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`
**Canvas Tools (for visual workflow builder):**
- `validate_canvas_intent` - Validate a canvas-generated optimization intent
- `execute_canvas_intent` - Create a study from a canvas intent
- `interpret_canvas_intent` - Analyze intent and provide recommendations
When you receive a message containing "INTENT:" followed by JSON, this is from the Canvas UI.
Parse the intent and use the appropriate canvas tool to process it.
In user mode you can:
- Read and explain study configurations
- Analyze optimization results
- Provide recommendations
- Answer questions about FEA and optimization
"""

View File

@@ -0,0 +1,454 @@
"""
Interview Engine - Guided Study Creation through Conversation
Provides a structured interview flow for creating optimization studies.
Claude uses this to gather information step-by-step, building a complete
atomizer_spec.json through natural conversation.
"""
from typing import Dict, Any, List, Optional, Literal
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
import json
class InterviewState(str, Enum):
"""Current phase of the interview"""
NOT_STARTED = "not_started"
GATHERING_BASICS = "gathering_basics" # Name, description, goals
GATHERING_MODEL = "gathering_model" # Model file, solver type
GATHERING_VARIABLES = "gathering_variables" # Design variables
GATHERING_EXTRACTORS = "gathering_extractors" # Physics extractors
GATHERING_OBJECTIVES = "gathering_objectives" # Objectives
GATHERING_CONSTRAINTS = "gathering_constraints" # Constraints
GATHERING_SETTINGS = "gathering_settings" # Algorithm, trials
REVIEW = "review" # Review before creation
COMPLETED = "completed"
@dataclass
class InterviewData:
"""Accumulated data from the interview"""
# Basics
study_name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
goals: List[str] = field(default_factory=list)
# Model
sim_file: Optional[str] = None
prt_file: Optional[str] = None
solver_type: str = "nastran"
# Design variables
design_variables: List[Dict[str, Any]] = field(default_factory=list)
# Extractors
extractors: List[Dict[str, Any]] = field(default_factory=list)
# Objectives
objectives: List[Dict[str, Any]] = field(default_factory=list)
# Constraints
constraints: List[Dict[str, Any]] = field(default_factory=list)
# Settings
algorithm: str = "TPE"
max_trials: int = 100
def to_spec(self) -> Dict[str, Any]:
"""Convert interview data to atomizer_spec.json format"""
# Generate IDs for each element
dvs_with_ids = []
for i, dv in enumerate(self.design_variables):
dv_copy = dv.copy()
dv_copy['id'] = f"dv_{i+1:03d}"
dv_copy['canvas_position'] = {'x': 50, 'y': 100 + i * 80}
dvs_with_ids.append(dv_copy)
exts_with_ids = []
for i, ext in enumerate(self.extractors):
ext_copy = ext.copy()
ext_copy['id'] = f"ext_{i+1:03d}"
ext_copy['canvas_position'] = {'x': 400, 'y': 100 + i * 80}
exts_with_ids.append(ext_copy)
objs_with_ids = []
for i, obj in enumerate(self.objectives):
obj_copy = obj.copy()
obj_copy['id'] = f"obj_{i+1:03d}"
obj_copy['canvas_position'] = {'x': 750, 'y': 100 + i * 80}
objs_with_ids.append(obj_copy)
cons_with_ids = []
for i, con in enumerate(self.constraints):
con_copy = con.copy()
con_copy['id'] = f"con_{i+1:03d}"
con_copy['canvas_position'] = {'x': 750, 'y': 400 + i * 80}
cons_with_ids.append(con_copy)
return {
"meta": {
"version": "2.0",
"study_name": self.study_name or "untitled_study",
"description": self.description or "",
"created_at": datetime.now().isoformat(),
"created_by": "interview",
"modified_at": datetime.now().isoformat(),
"modified_by": "interview"
},
"model": {
"sim": {
"path": self.sim_file or "",
"solver": self.solver_type
}
},
"design_variables": dvs_with_ids,
"extractors": exts_with_ids,
"objectives": objs_with_ids,
"constraints": cons_with_ids,
"optimization": {
"algorithm": {
"type": self.algorithm
},
"budget": {
"max_trials": self.max_trials
}
},
"canvas": {
"edges": [],
"layout_version": "2.0"
}
}
class InterviewEngine:
"""
Manages the interview flow for study creation.
Usage:
1. Create engine: engine = InterviewEngine()
2. Start interview: engine.start()
3. Record answers: engine.record_answer("study_name", "bracket_opt")
4. Check progress: engine.get_progress()
5. Generate spec: engine.finalize()
"""
def __init__(self):
self.state = InterviewState.NOT_STARTED
self.data = InterviewData()
self.questions_asked: List[str] = []
self.errors: List[str] = []
def start(self) -> Dict[str, Any]:
"""Start the interview process"""
self.state = InterviewState.GATHERING_BASICS
return {
"state": self.state.value,
"message": "Let's create a new optimization study! I'll guide you through the process.",
"next_questions": self.get_current_questions()
}
def get_current_questions(self) -> List[Dict[str, Any]]:
"""Get the questions for the current interview state"""
questions = {
InterviewState.GATHERING_BASICS: [
{
"field": "study_name",
"question": "What would you like to name this study?",
"hint": "Use snake_case, e.g., 'bracket_mass_optimization'",
"required": True
},
{
"field": "category",
"question": "What category should this study be in?",
"hint": "e.g., 'Simple_Bracket', 'M1_Mirror', or leave blank for root",
"required": False
},
{
"field": "description",
"question": "Briefly describe what you're trying to optimize",
"hint": "e.g., 'Minimize bracket mass while maintaining stiffness'",
"required": True
}
],
InterviewState.GATHERING_MODEL: [
{
"field": "sim_file",
"question": "What is the path to your simulation (.sim) file?",
"hint": "Relative path from the study folder, e.g., '1_setup/Model_sim1.sim'",
"required": True
}
],
InterviewState.GATHERING_VARIABLES: [
{
"field": "design_variable",
"question": "What parameters do you want to optimize?",
"hint": "Tell me the NX expression names and their bounds",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_EXTRACTORS: [
{
"field": "extractor",
"question": "What physics quantities do you want to extract from FEA?",
"hint": "e.g., mass, max displacement, max stress, frequency, Zernike WFE",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_OBJECTIVES: [
{
"field": "objective",
"question": "What do you want to optimize?",
"hint": "Tell me which extracted quantities to minimize or maximize",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_CONSTRAINTS: [
{
"field": "constraint",
"question": "Do you have any constraints? (e.g., max stress, min frequency)",
"hint": "You can say 'none' if you don't have any",
"required": False,
"multi": True
}
],
InterviewState.GATHERING_SETTINGS: [
{
"field": "algorithm",
"question": "Which optimization algorithm would you like to use?",
"hint": "Options: TPE (default), CMA-ES, NSGA-II, RandomSearch",
"required": False
},
{
"field": "max_trials",
"question": "How many trials (FEA evaluations) should we run?",
"hint": "Default is 100. More trials = better results but longer runtime",
"required": False
}
],
InterviewState.REVIEW: [
{
"field": "confirm",
"question": "Does this configuration look correct? (yes/no)",
"required": True
}
]
}
return questions.get(self.state, [])
def record_answer(self, field: str, value: Any) -> Dict[str, Any]:
"""Record an answer and potentially advance the state"""
self.questions_asked.append(field)
# Handle different field types
if field == "study_name":
self.data.study_name = value
elif field == "category":
self.data.category = value if value else None
elif field == "description":
self.data.description = value
elif field == "sim_file":
self.data.sim_file = value
elif field == "design_variable":
# Value should be a dict with name, min, max, etc.
if isinstance(value, dict):
self.data.design_variables.append(value)
elif isinstance(value, list):
self.data.design_variables.extend(value)
elif field == "extractor":
if isinstance(value, dict):
self.data.extractors.append(value)
elif isinstance(value, list):
self.data.extractors.extend(value)
elif field == "objective":
if isinstance(value, dict):
self.data.objectives.append(value)
elif isinstance(value, list):
self.data.objectives.extend(value)
elif field == "constraint":
if value and value.lower() not in ["none", "no", "skip"]:
if isinstance(value, dict):
self.data.constraints.append(value)
elif isinstance(value, list):
self.data.constraints.extend(value)
elif field == "algorithm":
if value in ["TPE", "CMA-ES", "NSGA-II", "RandomSearch"]:
self.data.algorithm = value
elif field == "max_trials":
try:
self.data.max_trials = int(value)
except (ValueError, TypeError):
pass
elif field == "confirm":
if value.lower() in ["yes", "y", "confirm", "ok"]:
self.state = InterviewState.COMPLETED
return {
"state": self.state.value,
"recorded": {field: value},
"data_so_far": self.get_summary()
}
def advance_state(self) -> Dict[str, Any]:
"""Advance to the next interview state"""
state_order = [
InterviewState.NOT_STARTED,
InterviewState.GATHERING_BASICS,
InterviewState.GATHERING_MODEL,
InterviewState.GATHERING_VARIABLES,
InterviewState.GATHERING_EXTRACTORS,
InterviewState.GATHERING_OBJECTIVES,
InterviewState.GATHERING_CONSTRAINTS,
InterviewState.GATHERING_SETTINGS,
InterviewState.REVIEW,
InterviewState.COMPLETED
]
current_idx = state_order.index(self.state)
if current_idx < len(state_order) - 1:
self.state = state_order[current_idx + 1]
return {
"state": self.state.value,
"next_questions": self.get_current_questions()
}
def get_summary(self) -> Dict[str, Any]:
"""Get a summary of collected data"""
return {
"study_name": self.data.study_name,
"category": self.data.category,
"description": self.data.description,
"model": self.data.sim_file,
"design_variables": len(self.data.design_variables),
"extractors": len(self.data.extractors),
"objectives": len(self.data.objectives),
"constraints": len(self.data.constraints),
"algorithm": self.data.algorithm,
"max_trials": self.data.max_trials
}
def get_progress(self) -> Dict[str, Any]:
"""Get interview progress information"""
state_progress = {
InterviewState.NOT_STARTED: 0,
InterviewState.GATHERING_BASICS: 15,
InterviewState.GATHERING_MODEL: 25,
InterviewState.GATHERING_VARIABLES: 40,
InterviewState.GATHERING_EXTRACTORS: 55,
InterviewState.GATHERING_OBJECTIVES: 70,
InterviewState.GATHERING_CONSTRAINTS: 80,
InterviewState.GATHERING_SETTINGS: 90,
InterviewState.REVIEW: 95,
InterviewState.COMPLETED: 100
}
return {
"state": self.state.value,
"progress_percent": state_progress.get(self.state, 0),
"summary": self.get_summary(),
"current_questions": self.get_current_questions()
}
def validate(self) -> Dict[str, Any]:
"""Validate the collected data before finalizing"""
errors = []
warnings = []
# Required fields
if not self.data.study_name:
errors.append("Study name is required")
if not self.data.design_variables:
errors.append("At least one design variable is required")
if not self.data.extractors:
errors.append("At least one extractor is required")
if not self.data.objectives:
errors.append("At least one objective is required")
# Warnings
if not self.data.sim_file:
warnings.append("No simulation file specified - you'll need to add one manually")
if not self.data.constraints:
warnings.append("No constraints defined - optimization will be unconstrained")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings
}
def finalize(self) -> Dict[str, Any]:
"""Generate the final atomizer_spec.json"""
validation = self.validate()
if not validation["valid"]:
return {
"success": False,
"errors": validation["errors"]
}
spec = self.data.to_spec()
return {
"success": True,
"spec": spec,
"warnings": validation.get("warnings", [])
}
def to_dict(self) -> Dict[str, Any]:
"""Serialize engine state for persistence"""
return {
"state": self.state.value,
"data": {
"study_name": self.data.study_name,
"category": self.data.category,
"description": self.data.description,
"goals": self.data.goals,
"sim_file": self.data.sim_file,
"prt_file": self.data.prt_file,
"solver_type": self.data.solver_type,
"design_variables": self.data.design_variables,
"extractors": self.data.extractors,
"objectives": self.data.objectives,
"constraints": self.data.constraints,
"algorithm": self.data.algorithm,
"max_trials": self.data.max_trials
},
"questions_asked": self.questions_asked,
"errors": self.errors
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "InterviewEngine":
"""Restore engine from serialized state"""
engine = cls()
engine.state = InterviewState(data.get("state", "not_started"))
d = data.get("data", {})
engine.data.study_name = d.get("study_name")
engine.data.category = d.get("category")
engine.data.description = d.get("description")
engine.data.goals = d.get("goals", [])
engine.data.sim_file = d.get("sim_file")
engine.data.prt_file = d.get("prt_file")
engine.data.solver_type = d.get("solver_type", "nastran")
engine.data.design_variables = d.get("design_variables", [])
engine.data.extractors = d.get("extractors", [])
engine.data.objectives = d.get("objectives", [])
engine.data.constraints = d.get("constraints", [])
engine.data.algorithm = d.get("algorithm", "TPE")
engine.data.max_trials = d.get("max_trials", 100)
engine.questions_asked = data.get("questions_asked", [])
engine.errors = data.get("errors", [])
return engine

View File

@@ -1,11 +1,15 @@
"""
Session Manager
Manages persistent Claude Code sessions with MCP integration.
Manages persistent Claude Code sessions with direct file editing.
Fixed for Windows compatibility - uses subprocess.Popen with ThreadPoolExecutor.
Strategy: Claude edits atomizer_spec.json directly using Edit/Write tools
(no MCP dependency for reliability).
"""
import asyncio
import hashlib
import json
import os
import subprocess
@@ -26,6 +30,10 @@ MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools"
# Thread pool for subprocess operations (Windows compatible)
_executor = ThreadPoolExecutor(max_workers=4)
import logging
logger = logging.getLogger(__name__)
@dataclass
class ClaudeSession:
@@ -130,6 +138,7 @@ class SessionManager:
Send a message to a session and stream the response.
Uses synchronous subprocess.Popen via ThreadPoolExecutor for Windows compatibility.
Claude edits atomizer_spec.json directly using Edit/Write tools (no MCP).
Args:
session_id: The session ID
@@ -147,45 +156,48 @@ class SessionManager:
# Store user message
self.store.add_message(session_id, "user", message)
# Get spec path and hash BEFORE Claude runs (to detect changes)
spec_path = self._get_spec_path(session.study_id) if session.study_id else None
spec_hash_before = self._get_file_hash(spec_path) if spec_path else None
# Build context with conversation history AND canvas state
history = self.store.get_history(session_id, limit=10)
full_prompt = self.context_builder.build(
mode=session.mode,
study_id=session.study_id,
conversation_history=history[:-1],
canvas_state=canvas_state, # Pass canvas state for context
canvas_state=canvas_state,
spec_path=str(spec_path) if spec_path else None, # Tell Claude where the spec is
)
full_prompt += f"\n\nUser: {message}\n\nRespond helpfully and concisely:"
# Build CLI arguments
# Build CLI arguments - NO MCP for reliability
cli_args = ["claude", "--print"]
# Ensure MCP config exists
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
if not mcp_config_path.exists():
mcp_config = self._build_mcp_config(session.mode)
with open(mcp_config_path, "w") as f:
json.dump(mcp_config, f)
cli_args.extend(["--mcp-config", str(mcp_config_path)])
if session.mode == "user":
cli_args.extend([
"--allowedTools",
"Read Write(**/STUDY_REPORT.md) Write(**/3_results/*.md) Bash(python:*) mcp__atomizer-tools__*"
])
# User mode: limited tools
cli_args.extend(
[
"--allowedTools",
"Read Bash(python:*)",
]
)
else:
# Power mode: full access to edit files
cli_args.append("--dangerously-skip-permissions")
cli_args.append("-") # Read from stdin
full_response = ""
tool_calls: List[Dict] = []
process: Optional[subprocess.Popen] = None
try:
loop = asyncio.get_event_loop()
# Run subprocess in thread pool (Windows compatible)
def run_claude():
nonlocal process
try:
process = subprocess.Popen(
cli_args,
@@ -194,8 +206,8 @@ class SessionManager:
stderr=subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
text=True,
encoding='utf-8',
errors='replace',
encoding="utf-8",
errors="replace",
)
stdout, stderr = process.communicate(input=full_prompt, timeout=300)
return {
@@ -204,10 +216,13 @@ class SessionManager:
"returncode": process.returncode,
}
except subprocess.TimeoutExpired:
process.kill()
if process:
process.kill()
return {"error": "Response timeout (5 minutes)"}
except FileNotFoundError:
return {"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"}
return {
"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"
}
except Exception as e:
return {"error": str(e)}
@@ -219,12 +234,14 @@ class SessionManager:
full_response = result["stdout"] or ""
if full_response:
# Always send the text response first
yield {"type": "text", "content": full_response}
if result["returncode"] != 0 and result["stderr"]:
yield {"type": "error", "message": f"CLI error: {result['stderr']}"}
logger.warning(f"[SEND_MSG] CLI stderr: {result['stderr']}")
except Exception as e:
logger.error(f"[SEND_MSG] Exception: {e}")
yield {"type": "error", "message": str(e)}
# Store assistant response
@@ -236,8 +253,46 @@ class SessionManager:
tool_calls=tool_calls if tool_calls else None,
)
# Check if spec was modified by comparing hashes
if spec_path and session.mode == "power" and session.study_id:
spec_hash_after = self._get_file_hash(spec_path)
if spec_hash_before != spec_hash_after:
logger.info(f"[SEND_MSG] Spec file was modified! Sending update.")
spec_update = await self._check_spec_updated(session.study_id)
if spec_update:
yield {
"type": "spec_updated",
"spec": spec_update,
"tool": "direct_edit",
"reason": "Claude modified spec file directly",
}
yield {"type": "done", "tool_calls": tool_calls}
def _get_spec_path(self, study_id: str) -> Optional[Path]:
"""Get the atomizer_spec.json path for a study."""
if not study_id:
return None
if study_id.startswith("draft_"):
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
else:
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
if not spec_path.exists():
spec_path = ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
return spec_path if spec_path.exists() else None
def _get_file_hash(self, path: Optional[Path]) -> Optional[str]:
"""Get MD5 hash of a file for change detection."""
if not path or not path.exists():
return None
try:
with open(path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
except Exception:
return None
async def switch_mode(
self,
session_id: str,
@@ -292,6 +347,132 @@ class SessionManager:
**({} if not db_record else {"db_record": db_record}),
}
def _extract_canvas_modifications(self, response: str) -> List[Dict]:
"""
Extract canvas modification objects from Claude's response.
MCP tools like canvas_add_node return JSON with a 'modification' field.
This method finds and extracts those modifications so the frontend can apply them.
"""
import re
import logging
logger = logging.getLogger(__name__)
modifications = []
# Debug: log what we're searching
logger.info(f"[CANVAS_MOD] Searching response ({len(response)} chars) for modifications")
# Check if "modification" even exists in the response
if '"modification"' not in response:
logger.info("[CANVAS_MOD] No 'modification' key found in response")
return modifications
try:
# Method 1: Look for JSON in code fences
code_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
for match in re.finditer(code_block_pattern, response):
block_content = match.group(1).strip()
try:
obj = json.loads(block_content)
if isinstance(obj, dict) and "modification" in obj:
logger.info(
f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}"
)
modifications.append(obj["modification"])
except json.JSONDecodeError:
continue
# Method 2: Find JSON objects using proper brace matching
# This handles nested objects correctly
i = 0
while i < len(response):
if response[i] == "{":
# Found a potential JSON start, find matching close
brace_count = 1
j = i + 1
in_string = False
escape_next = False
while j < len(response) and brace_count > 0:
char = response[j]
if escape_next:
escape_next = False
elif char == "\\":
escape_next = True
elif char == '"' and not escape_next:
in_string = not in_string
elif not in_string:
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
j += 1
if brace_count == 0:
potential_json = response[i:j]
try:
obj = json.loads(potential_json)
if isinstance(obj, dict) and "modification" in obj:
mod = obj["modification"]
# Avoid duplicates
if mod not in modifications:
logger.info(
f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}"
)
modifications.append(mod)
except json.JSONDecodeError as e:
# Not valid JSON, skip
pass
i = j
else:
i += 1
except Exception as e:
logger.error(f"[CANVAS_MOD] Error extracting modifications: {e}")
logger.info(f"[CANVAS_MOD] Extracted {len(modifications)} modification(s)")
return modifications
async def _check_spec_updated(self, study_id: str) -> Optional[Dict]:
"""
Check if the atomizer_spec.json was modified and return the updated spec.
For drafts in _inbox/, we check the spec file directly.
"""
import logging
logger = logging.getLogger(__name__)
try:
# Determine spec path based on study_id
if study_id.startswith("draft_"):
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
else:
# Regular study path
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
if not spec_path.exists():
spec_path = (
ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
)
if not spec_path.exists():
logger.debug(f"[SPEC_CHECK] Spec not found at {spec_path}")
return None
# Read and return the spec
with open(spec_path, "r", encoding="utf-8") as f:
spec = json.load(f)
logger.info(f"[SPEC_CHECK] Loaded spec from {spec_path}")
return spec
except Exception as e:
logger.error(f"[SPEC_CHECK] Error checking spec: {e}")
return None
def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict:
"""Build MCP configuration for Claude"""
return {

View File

@@ -0,0 +1,827 @@
"""
SpecManager Service
Central service for managing AtomizerSpec v2.0.
All spec modifications flow through this service.
Features:
- Load/save specs with validation
- Atomic writes with conflict detection
- Patch operations with JSONPath support
- Node CRUD operations
- Custom function support
- WebSocket broadcast integration
"""
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
# Add optimization_engine to path if needed
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
if str(ATOMIZER_ROOT) not in sys.path:
sys.path.insert(0, str(ATOMIZER_ROOT))
from optimization_engine.config.spec_models import (
AtomizerSpec,
DesignVariable,
Extractor,
Objective,
Constraint,
CanvasPosition,
CanvasEdge,
ExtractorType,
CustomFunction,
ExtractorOutput,
ValidationReport,
)
from optimization_engine.config.spec_validator import (
SpecValidator,
SpecValidationError,
)
class SpecManagerError(Exception):
"""Base error for SpecManager operations."""
pass
class SpecNotFoundError(SpecManagerError):
"""Raised when spec file doesn't exist."""
pass
class SpecConflictError(SpecManagerError):
"""Raised when spec has been modified by another client."""
def __init__(self, message: str, current_hash: str):
super().__init__(message)
self.current_hash = current_hash
class WebSocketSubscriber:
"""Protocol for WebSocket subscribers."""
async def send_json(self, data: Dict[str, Any]) -> None:
"""Send JSON data to subscriber."""
raise NotImplementedError
class SpecManager:
"""
Central service for managing AtomizerSpec.
All modifications go through this service to ensure:
- Validation on every change
- Atomic file writes
- Conflict detection via hashing
- WebSocket broadcast to all clients
"""
SPEC_FILENAME = "atomizer_spec.json"
def __init__(self, study_path: Union[str, Path]):
"""
Initialize SpecManager for a study.
Args:
study_path: Path to the study directory
"""
self.study_path = Path(study_path)
self.spec_path = self.study_path / self.SPEC_FILENAME
self.validator = SpecValidator()
self._subscribers: List[WebSocketSubscriber] = []
self._last_hash: Optional[str] = None
# =========================================================================
# Core CRUD Operations
# =========================================================================
def load(self, validate: bool = True) -> AtomizerSpec:
"""
Load and optionally validate the spec.
Args:
validate: Whether to validate the spec
Returns:
AtomizerSpec instance
Raises:
SpecNotFoundError: If spec file doesn't exist
SpecValidationError: If validation fails
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, "r", encoding="utf-8") as f:
data = json.load(f)
if validate:
self.validator.validate(data, strict=True)
spec = AtomizerSpec.model_validate(data)
self._last_hash = self._compute_hash(data)
return spec
def load_raw(self) -> Dict[str, Any]:
"""
Load spec as raw dict without parsing.
Returns:
Raw spec dict
Raises:
SpecNotFoundError: If spec file doesn't exist
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, "r", encoding="utf-8") as f:
return json.load(f)
def save(
self,
spec: Union[AtomizerSpec, Dict[str, Any]],
modified_by: str = "api",
expected_hash: Optional[str] = None,
skip_validation: bool = False,
) -> str:
"""
Save spec with validation and broadcast.
Args:
spec: Spec to save (AtomizerSpec or dict)
modified_by: Who/what is making the change
expected_hash: If provided, verify current file hash matches
skip_validation: If True, skip strict validation (for draft specs)
Returns:
New spec hash
Raises:
SpecValidationError: If validation fails
SpecConflictError: If expected_hash doesn't match current
"""
# Convert to dict if needed
if isinstance(spec, AtomizerSpec):
data = spec.model_dump(mode="json")
else:
data = spec
# Check for conflicts if expected_hash provided
if expected_hash and self.spec_path.exists():
current_hash = self.get_hash()
if current_hash != expected_hash:
raise SpecConflictError(
"Spec was modified by another client", current_hash=current_hash
)
# Update metadata
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
data["meta"]["modified"] = now
data["meta"]["modified_by"] = modified_by
# Validate (skip for draft specs or when explicitly requested)
status = data.get("meta", {}).get("status", "draft")
is_draft = status in ("draft", "introspected", "configured")
if not skip_validation and not is_draft:
self.validator.validate(data, strict=True)
elif not skip_validation:
# For draft specs, just validate non-strictly (collect warnings only)
self.validator.validate(data, strict=False)
# Compute new hash
new_hash = self._compute_hash(data)
# Atomic write (write to temp, then rename)
temp_path = self.spec_path.with_suffix(".tmp")
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
temp_path.replace(self.spec_path)
# Update cached hash
self._last_hash = new_hash
# Broadcast to subscribers
self._broadcast(
{"type": "spec_updated", "hash": new_hash, "modified_by": modified_by, "timestamp": now}
)
return new_hash
def exists(self) -> bool:
"""Check if spec file exists."""
return self.spec_path.exists()
def get_hash(self) -> str:
"""Get current spec hash."""
if not self.spec_path.exists():
return ""
with open(self.spec_path, "r", encoding="utf-8") as f:
data = json.load(f)
return self._compute_hash(data)
def validate_and_report(self) -> ValidationReport:
"""
Run full validation and return detailed report.
Returns:
ValidationReport with errors, warnings, summary
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
data = self.load_raw()
return self.validator.validate(data, strict=False)
# =========================================================================
# Patch Operations
# =========================================================================
def patch(self, path: str, value: Any, modified_by: str = "api") -> AtomizerSpec:
"""
Apply a JSONPath-style modification.
Args:
path: JSONPath like "design_variables[0].bounds.max"
value: New value to set
modified_by: Who/what is making the change
Returns:
Updated AtomizerSpec
"""
data = self.load_raw()
# Validate the partial update
spec = AtomizerSpec.model_validate(data)
is_valid, errors = self.validator.validate_partial(path, value, spec)
if not is_valid:
raise SpecValidationError(f"Invalid update: {'; '.join(errors)}")
# Apply the patch
self._apply_patch(data, path, value)
# Save and return
self.save(data, modified_by)
return self.load(validate=False)
def _apply_patch(self, data: Dict, path: str, value: Any) -> None:
"""
Apply a patch to the data dict.
Supports paths like:
- "meta.description"
- "design_variables[0].bounds.max"
- "objectives[1].weight"
"""
parts = self._parse_path(path)
if not parts:
raise ValueError(f"Invalid path: {path}")
# Navigate to parent
current = data
for part in parts[:-1]:
if isinstance(current, list):
idx = int(part)
current = current[idx]
else:
current = current[part]
# Set final value
final_key = parts[-1]
if isinstance(current, list):
idx = int(final_key)
current[idx] = value
else:
current[final_key] = value
def _parse_path(self, path: str) -> List[str]:
"""Parse JSONPath into parts."""
# Handle both dot notation and bracket notation
parts = []
for part in re.split(r"\.|\[|\]", path):
if part:
parts.append(part)
return parts
# =========================================================================
# Node Operations
# =========================================================================
def add_node(
self, node_type: str, node_data: Dict[str, Any], modified_by: str = "canvas"
) -> str:
"""
Add a new node (design var, extractor, objective, constraint).
Args:
node_type: One of 'designVar', 'extractor', 'objective', 'constraint'
node_data: Node data without ID
modified_by: Who/what is making the change
Returns:
Generated node ID
"""
data = self.load_raw()
# Generate ID
node_id = self._generate_id(node_type, data)
node_data["id"] = node_id
# Add canvas position if not provided
if "canvas_position" not in node_data:
node_data["canvas_position"] = self._auto_position(node_type, data)
# Add to appropriate section
section = self._get_section_for_type(node_type)
if section not in data or data[section] is None:
data[section] = []
data[section].append(node_data)
self.save(data, modified_by)
# Broadcast node addition
self._broadcast(
{
"type": "node_added",
"node_type": node_type,
"node_id": node_id,
"modified_by": modified_by,
}
)
return node_id
def update_node(
self, node_id: str, updates: Dict[str, Any], modified_by: str = "canvas"
) -> None:
"""
Update an existing node.
Args:
node_id: ID of the node to update
updates: Dict of fields to update
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find and update the node
found = False
for section in ["design_variables", "extractors", "objectives", "constraints"]:
if section not in data or data[section] is None:
continue
for node in data[section]:
if node.get("id") == node_id:
node.update(updates)
found = True
break
if found:
break
if not found:
raise SpecManagerError(f"Node not found: {node_id}")
self.save(data, modified_by)
def remove_node(self, node_id: str, modified_by: str = "canvas") -> None:
"""
Remove a node and all edges referencing it.
Args:
node_id: ID of the node to remove
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find and remove node
removed = False
for section in ["design_variables", "extractors", "objectives", "constraints"]:
if section not in data or data[section] is None:
continue
original_len = len(data[section])
data[section] = [n for n in data[section] if n.get("id") != node_id]
if len(data[section]) < original_len:
removed = True
break
if not removed:
raise SpecManagerError(f"Node not found: {node_id}")
# Remove edges referencing this node
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e
for e in data["canvas"]["edges"]
if e.get("source") != node_id and e.get("target") != node_id
]
self.save(data, modified_by)
# Broadcast node removal
self._broadcast({"type": "node_removed", "node_id": node_id, "modified_by": modified_by})
def update_node_position(
self, node_id: str, position: Dict[str, float], modified_by: str = "canvas"
) -> None:
"""
Update a node's canvas position.
Args:
node_id: ID of the node
position: Dict with x, y coordinates
modified_by: Who/what is making the change
"""
self.update_node(node_id, {"canvas_position": position}, modified_by)
def add_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
"""
Add a canvas edge between nodes.
Args:
source: Source node ID
target: Target node ID
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Initialize canvas section if needed
if "canvas" not in data or data["canvas"] is None:
data["canvas"] = {}
if "edges" not in data["canvas"] or data["canvas"]["edges"] is None:
data["canvas"]["edges"] = []
# Check for duplicate
for edge in data["canvas"]["edges"]:
if edge.get("source") == source and edge.get("target") == target:
return # Already exists
data["canvas"]["edges"].append({"source": source, "target": target})
self.save(data, modified_by)
def remove_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
"""
Remove a canvas edge.
Args:
source: Source node ID
target: Target node ID
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e
for e in data["canvas"]["edges"]
if not (e.get("source") == source and e.get("target") == target)
]
self.save(data, modified_by)
# =========================================================================
# Custom Function Support
# =========================================================================
def add_custom_function(
self,
name: str,
code: str,
outputs: List[str],
description: Optional[str] = None,
modified_by: str = "claude",
) -> str:
"""
Add a custom extractor function.
Args:
name: Function name
code: Python source code
outputs: List of output names
description: Optional description
modified_by: Who/what is making the change
Returns:
Generated extractor ID
Raises:
SpecValidationError: If Python syntax is invalid
"""
# Validate Python syntax
try:
compile(code, f"<custom:{name}>", "exec")
except SyntaxError as e:
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
data = self.load_raw()
# Generate extractor ID
ext_id = self._generate_id("extractor", data)
# Create extractor
extractor = {
"id": ext_id,
"name": description or f"Custom: {name}",
"type": "custom_function",
"builtin": False,
"function": {"name": name, "module": "custom_extractors.dynamic", "source_code": code},
"outputs": [{"name": o, "metric": "custom"} for o in outputs],
"canvas_position": self._auto_position("extractor", data),
}
data["extractors"].append(extractor)
self.save(data, modified_by)
return ext_id
def update_custom_function(
self,
extractor_id: str,
code: Optional[str] = None,
outputs: Optional[List[str]] = None,
modified_by: str = "claude",
) -> None:
"""
Update an existing custom function.
Args:
extractor_id: ID of the custom extractor
code: New Python code (optional)
outputs: New outputs (optional)
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find the extractor
extractor = None
for ext in data.get("extractors", []):
if ext.get("id") == extractor_id:
extractor = ext
break
if not extractor:
raise SpecManagerError(f"Extractor not found: {extractor_id}")
if extractor.get("type") != "custom_function":
raise SpecManagerError(f"Extractor {extractor_id} is not a custom function")
# Update code
if code is not None:
try:
compile(code, f"<custom:{extractor_id}>", "exec")
except SyntaxError as e:
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
if "function" not in extractor:
extractor["function"] = {}
extractor["function"]["source_code"] = code
# Update outputs
if outputs is not None:
extractor["outputs"] = [{"name": o, "metric": "custom"} for o in outputs]
self.save(data, modified_by)
# =========================================================================
# WebSocket Subscription
# =========================================================================
def subscribe(self, subscriber: WebSocketSubscriber) -> None:
"""Subscribe to spec changes."""
if subscriber not in self._subscribers:
self._subscribers.append(subscriber)
def unsubscribe(self, subscriber: WebSocketSubscriber) -> None:
"""Unsubscribe from spec changes."""
if subscriber in self._subscribers:
self._subscribers.remove(subscriber)
def _broadcast(self, message: Dict[str, Any]) -> None:
"""Broadcast message to all subscribers."""
import asyncio
for subscriber in self._subscribers:
try:
# Handle both sync and async contexts
try:
loop = asyncio.get_running_loop()
loop.create_task(subscriber.send_json(message))
except RuntimeError:
# No running loop, try direct call if possible
pass
except Exception:
# Subscriber may have disconnected
pass
# =========================================================================
# Helper Methods
# =========================================================================
def _compute_hash(self, data: Dict) -> str:
"""Compute hash of spec data for conflict detection."""
# Sort keys for consistent hashing
json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(json_str.encode()).hexdigest()[:16]
def _generate_id(self, node_type: str, data: Dict) -> str:
"""Generate unique ID for a node type."""
prefix_map = {
"designVar": "dv",
"design_variable": "dv",
"extractor": "ext",
"objective": "obj",
"constraint": "con",
}
prefix = prefix_map.get(node_type, node_type[:3])
# Find existing IDs
section = self._get_section_for_type(node_type)
existing_ids: Set[str] = set()
if section in data and data[section]:
existing_ids = {n.get("id", "") for n in data[section]}
# Generate next available ID
for i in range(1, 1000):
new_id = f"{prefix}_{i:03d}"
if new_id not in existing_ids:
return new_id
raise SpecManagerError(f"Cannot generate ID for {node_type}: too many nodes")
def _get_section_for_type(self, node_type: str) -> str:
"""Map node type to spec section name."""
section_map = {
"designVar": "design_variables",
"design_variable": "design_variables",
"extractor": "extractors",
"objective": "objectives",
"constraint": "constraints",
}
return section_map.get(node_type, node_type + "s")
def _auto_position(self, node_type: str, data: Dict) -> Dict[str, float]:
"""Calculate auto position for a new node."""
# Default x positions by type
x_positions = {
"designVar": 50,
"design_variable": 50,
"extractor": 740,
"objective": 1020,
"constraint": 1020,
}
x = x_positions.get(node_type, 400)
# Find max y position for this type
section = self._get_section_for_type(node_type)
max_y = 0
if section in data and data[section]:
for node in data[section]:
pos = node.get("canvas_position", {})
y = pos.get("y", 0)
if y > max_y:
max_y = y
# Place below existing nodes
y = max_y + 100 if max_y > 0 else 100
return {"x": x, "y": y}
# =========================================================================
# Intake Workflow Methods
# =========================================================================
def update_status(self, status: str, modified_by: str = "api") -> None:
"""
Update the spec status field.
Args:
status: New status (draft, introspected, configured, validated, ready, running, completed, failed)
modified_by: Who/what is making the change
"""
data = self.load_raw()
data["meta"]["status"] = status
self.save(data, modified_by)
def get_status(self) -> str:
"""
Get the current spec status.
Returns:
Current status string
"""
if not self.exists():
return "unknown"
data = self.load_raw()
return data.get("meta", {}).get("status", "draft")
def add_introspection(
self, introspection_data: Dict[str, Any], modified_by: str = "introspection"
) -> None:
"""
Add introspection data to the spec's model section.
Args:
introspection_data: Dict with timestamp, expressions, mass_kg, etc.
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "model" not in data:
data["model"] = {}
data["model"]["introspection"] = introspection_data
data["meta"]["status"] = "introspected"
self.save(data, modified_by)
def add_baseline(
self, baseline_data: Dict[str, Any], modified_by: str = "baseline_solve"
) -> None:
"""
Add baseline solve results to introspection data.
Args:
baseline_data: Dict with timestamp, solve_time_seconds, mass_kg, etc.
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "model" not in data:
data["model"] = {}
if "introspection" not in data["model"] or data["model"]["introspection"] is None:
data["model"]["introspection"] = {}
data["model"]["introspection"]["baseline"] = baseline_data
# Update status based on baseline success
if baseline_data.get("success", False):
data["meta"]["status"] = "validated"
self.save(data, modified_by)
def set_topic(self, topic: str, modified_by: str = "api") -> None:
"""
Set the spec's topic field.
Args:
topic: Topic folder name
modified_by: Who/what is making the change
"""
data = self.load_raw()
data["meta"]["topic"] = topic
self.save(data, modified_by)
def get_introspection(self) -> Optional[Dict[str, Any]]:
"""
Get introspection data from spec.
Returns:
Introspection dict or None if not present
"""
if not self.exists():
return None
data = self.load_raw()
return data.get("model", {}).get("introspection")
def get_design_candidates(self) -> List[Dict[str, Any]]:
"""
Get expressions marked as design variable candidates.
Returns:
List of expression dicts where is_candidate=True
"""
introspection = self.get_introspection()
if not introspection:
return []
expressions = introspection.get("expressions", [])
return [e for e in expressions if e.get("is_candidate", False)]
# =========================================================================
# Factory Function
# =========================================================================
def get_spec_manager(study_path: Union[str, Path]) -> SpecManager:
"""
Get a SpecManager instance for a study.
Args:
study_path: Path to the study directory
Returns:
SpecManager instance
"""
return SpecManager(study_path)

View File

@@ -9,6 +9,7 @@ import Analysis from './pages/Analysis';
import Insights from './pages/Insights';
import Results from './pages/Results';
import CanvasView from './pages/CanvasView';
import Studio from './pages/Studio';
const queryClient = new QueryClient({
defaultOptions: {
@@ -30,6 +31,11 @@ function App() {
{/* Canvas page - full screen, no sidebar */}
<Route path="canvas" element={<CanvasView />} />
<Route path="canvas/*" element={<CanvasView />} />
{/* Studio - unified study creation environment */}
<Route path="studio" element={<Studio />} />
<Route path="studio/:draftId" element={<Studio />} />
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>

View File

@@ -0,0 +1,411 @@
/**
* Intake API Client
*
* API client methods for the study intake workflow.
*/
import {
CreateInboxRequest,
CreateInboxResponse,
IntrospectRequest,
IntrospectResponse,
ListInboxResponse,
ListTopicsResponse,
InboxStudyDetail,
GenerateReadmeResponse,
FinalizeRequest,
FinalizeResponse,
UploadFilesResponse,
} from '../types/intake';
const API_BASE = '/api';
/**
* Intake API client for study creation workflow.
*/
export const intakeApi = {
/**
* Create a new inbox study folder with initial spec.
*/
async createInbox(request: CreateInboxRequest): Promise<CreateInboxResponse> {
const response = await fetch(`${API_BASE}/intake/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create inbox study');
}
return response.json();
},
/**
* Run NX introspection on an inbox study.
*/
async introspect(request: IntrospectRequest): Promise<IntrospectResponse> {
const response = await fetch(`${API_BASE}/intake/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Introspection failed');
}
return response.json();
},
/**
* List all studies in the inbox.
*/
async listInbox(): Promise<ListInboxResponse> {
const response = await fetch(`${API_BASE}/intake/list`);
if (!response.ok) {
throw new Error('Failed to fetch inbox studies');
}
return response.json();
},
/**
* List existing topic folders.
*/
async listTopics(): Promise<ListTopicsResponse> {
const response = await fetch(`${API_BASE}/intake/topics`);
if (!response.ok) {
throw new Error('Failed to fetch topics');
}
return response.json();
},
/**
* Get detailed information about an inbox study.
*/
async getInboxStudy(studyName: string): Promise<InboxStudyDetail> {
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to fetch inbox study');
}
return response.json();
},
/**
* Delete an inbox study.
*/
async deleteInboxStudy(studyName: string): Promise<{ success: boolean; deleted: string }> {
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete inbox study');
}
return response.json();
},
/**
* Generate README for an inbox study using Claude AI.
*/
async generateReadme(studyName: string): Promise<GenerateReadmeResponse> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/readme`,
{ method: 'POST' }
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'README generation failed');
}
return response.json();
},
/**
* Finalize an inbox study and move to studies directory.
*/
async finalize(studyName: string, request: FinalizeRequest): Promise<FinalizeResponse> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Finalization failed');
}
return response.json();
},
/**
* Upload model files to an inbox study.
*/
async uploadFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/upload`,
{
method: 'POST',
body: formData,
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'File upload failed');
}
return response.json();
},
/**
* Upload context files to an inbox study.
* Context files help Claude understand optimization goals.
*/
async uploadContextFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`,
{
method: 'POST',
body: formData,
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Context file upload failed');
}
return response.json();
},
/**
* List context files for an inbox study.
*/
async listContextFiles(studyName: string): Promise<{
study_name: string;
context_files: Array<{ name: string; path: string; size: number; extension: string }>;
total: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`
);
if (!response.ok) {
throw new Error('Failed to list context files');
}
return response.json();
},
/**
* Delete a context file from an inbox study.
*/
async deleteContextFile(studyName: string, filename: string): Promise<{ success: boolean; deleted: string }> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete context file');
}
return response.json();
},
/**
* Create design variables from selected expressions.
*/
async createDesignVariables(
studyName: string,
expressionNames: string[],
options?: { autoBounds?: boolean; boundFactor?: number }
): Promise<{
success: boolean;
study_name: string;
created: Array<{
id: string;
name: string;
expression_name: string;
bounds_min: number;
bounds_max: number;
baseline: number;
units: string | null;
}>;
total_created: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/design-variables`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expression_names: expressionNames,
auto_bounds: options?.autoBounds ?? true,
bound_factor: options?.boundFactor ?? 0.5,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create design variables');
}
return response.json();
},
// ===========================================================================
// Studio Endpoints (Atomizer Studio - Unified Creation Environment)
// ===========================================================================
/**
* Create an anonymous draft study for Studio workflow.
* Returns a temporary draft_id that can be renamed during finalization.
*/
async createDraft(): Promise<{
success: boolean;
draft_id: string;
inbox_path: string;
spec_path: string;
status: string;
}> {
const response = await fetch(`${API_BASE}/intake/draft`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create draft');
}
return response.json();
},
/**
* Get extracted text content from context files.
* Used for AI context injection.
*/
async getContextContent(studyName: string): Promise<{
success: boolean;
study_name: string;
content: string;
files_read: Array<{
name: string;
extension: string;
size: number;
status: string;
characters?: number;
error?: string;
}>;
total_characters: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/content`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get context content');
}
return response.json();
},
/**
* Finalize a Studio draft with rename support.
* Enhanced version that supports renaming draft_xxx to proper names.
*/
async finalizeStudio(
studyName: string,
request: {
topic: string;
newName?: string;
runBaseline?: boolean;
}
): Promise<{
success: boolean;
original_name: string;
final_name: string;
final_path: string;
status: string;
baseline_success: boolean | null;
readme_generated: boolean;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize/studio`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: request.topic,
new_name: request.newName,
run_baseline: request.runBaseline ?? false,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Studio finalization failed');
}
return response.json();
},
/**
* Get complete draft information for Studio UI.
* Convenience endpoint that returns everything the Studio needs.
*/
async getStudioDraft(studyName: string): Promise<{
success: boolean;
draft_id: string;
spec: Record<string, unknown>;
model_files: string[];
context_files: string[];
introspection_available: boolean;
design_variable_count: number;
objective_count: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/studio`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get studio draft');
}
return response.json();
},
};
export default intakeApi;

View File

@@ -26,8 +26,8 @@ interface DesignVariable {
name: string;
parameter?: string; // Optional: the actual parameter name if different from name
unit?: string;
min: number;
max: number;
min?: number;
max?: number;
}
interface Constraint {

View File

@@ -8,14 +8,15 @@ import { ScatterChart, Scatter, Line, XAxis, YAxis, CartesianGrid, Tooltip, Cell
interface ParetoTrial {
trial_number: number;
values: [number, number];
values: number[]; // Support variable number of objectives
params: Record<string, number>;
constraint_satisfied?: boolean;
}
interface Objective {
name: string;
type: 'minimize' | 'maximize';
type?: 'minimize' | 'maximize';
direction?: 'minimize' | 'maximize'; // Alternative field used by some configs
unit?: string;
}

View File

@@ -0,0 +1,49 @@
/**
* ConnectionStatusIndicator - Visual indicator for WebSocket connection status.
*/
import { ConnectionStatus } from '../../hooks/useSpecWebSocket';
interface ConnectionStatusIndicatorProps {
status: ConnectionStatus;
className?: string;
}
/**
* Visual indicator for WebSocket connection status.
* Can be used in the canvas UI to show sync state.
*/
export function ConnectionStatusIndicator({
status,
className = '',
}: ConnectionStatusIndicatorProps) {
const statusConfig = {
disconnected: {
color: 'bg-gray-500',
label: 'Disconnected',
},
connecting: {
color: 'bg-yellow-500 animate-pulse',
label: 'Connecting...',
},
connected: {
color: 'bg-green-500',
label: 'Connected',
},
reconnecting: {
color: 'bg-yellow-500 animate-pulse',
label: 'Reconnecting...',
},
};
const config = statusConfig[status];
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={`w-2 h-2 rounded-full ${config.color}`} />
<span className="text-xs text-dark-400">{config.label}</span>
</div>
);
}
export default ConnectionStatusIndicator;

View File

@@ -0,0 +1,67 @@
/**
* ResizeHandle - Visual drag handle for resizable panels
*
* A thin vertical bar that can be dragged to resize panels.
* Shows visual feedback on hover and during drag.
*/
import { memo } from 'react';
interface ResizeHandleProps {
/** Mouse down handler to start dragging */
onMouseDown: (e: React.MouseEvent) => void;
/** Double click handler to reset size */
onDoubleClick?: () => void;
/** Whether panel is currently being dragged */
isDragging?: boolean;
/** Position of the handle ('left' or 'right' edge of the panel) */
position?: 'left' | 'right';
}
function ResizeHandleComponent({
onMouseDown,
onDoubleClick,
isDragging = false,
position = 'right',
}: ResizeHandleProps) {
return (
<div
className={`
absolute top-0 bottom-0 w-1 z-30
cursor-col-resize
transition-colors duration-150
${position === 'right' ? 'right-0' : 'left-0'}
${isDragging
? 'bg-primary-500'
: 'bg-transparent hover:bg-primary-500/50'
}
`}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
title="Drag to resize, double-click to reset"
>
{/* Wider hit area for easier grabbing */}
<div
className={`
absolute top-0 bottom-0 w-3
${position === 'right' ? '-left-1' : '-right-1'}
`}
/>
{/* Visual indicator dots (shown on hover via CSS) */}
<div className={`
absolute top-1/2 -translate-y-1/2
${position === 'right' ? '-left-0.5' : '-right-0.5'}
flex flex-col gap-1 opacity-0 hover:opacity-100 transition-opacity
${isDragging ? 'opacity-100' : ''}
`}>
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
</div>
</div>
);
}
export const ResizeHandle = memo(ResizeHandleComponent);
export default ResizeHandle;

View File

@@ -10,7 +10,8 @@
* P2.7-P2.10: SpecRenderer component with node/edge/selection handling
*/
import { useCallback, useRef, useEffect, useMemo, DragEvent } from 'react';
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import ReactFlow, {
Background,
Controls,
@@ -22,6 +23,7 @@ import ReactFlow, {
NodeChange,
EdgeChange,
Connection,
applyNodeChanges,
} from 'reactflow';
import 'reactflow/dist/style.css';
@@ -36,23 +38,34 @@ import {
useSelectedEdgeId,
} from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore';
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressRing } from './visualization/ConvergenceSparkline';
import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
// ============================================================================
// Drag-Drop Helpers
// ============================================================================
/** Addable node types via drag-drop */
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
import { SINGLETON_TYPES } from './palette/NodePalette';
/** All node types that can be added via drag-drop */
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
function isAddableNodeType(type: string): type is AddableNodeType {
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
}
/** Check if a node type is a singleton (only one allowed) */
function isSingletonType(type: string): boolean {
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
}
/** Maps canvas NodeType to spec API type */
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' {
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
return type;
}
@@ -61,6 +74,22 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
const timestamp = Date.now();
switch (type) {
case 'model':
return {
name: 'Model',
sim: {
path: '',
solver: 'nastran',
},
canvas_position: position,
};
case 'solver':
return {
name: 'Solver',
engine: 'nxnastran',
solution_type: 'SOL101',
canvas_position: position,
};
case 'designVar':
return {
name: `variable_${timestamp}`,
@@ -74,8 +103,28 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
case 'extractor':
return {
name: `extractor_${timestamp}`,
type: 'custom',
type: 'custom_function', // Must be valid ExtractorType
builtin: false,
enabled: true,
// Custom function extractors need a function definition
function: {
name: 'extract',
source_code: `def extract(op2_path: str, config: dict = None) -> dict:
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
config: Optional configuration dict
Returns:
Dictionary with extracted values
"""
# TODO: Implement extraction logic
return {'value': 0.0}
`,
},
outputs: [{ name: 'value', metric: 'custom' }],
canvas_position: position,
};
case 'objective':
@@ -83,20 +132,44 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
name: `objective_${timestamp}`,
direction: 'minimize',
weight: 1.0,
source_extractor_id: null,
source_output: null,
// Source is required - use placeholder that user must configure
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
canvas_position: position,
};
case 'constraint':
return {
name: `constraint_${timestamp}`,
type: 'upper',
limit: 1.0,
source_extractor_id: null,
source_output: null,
type: 'hard', // Must be 'hard' or 'soft' (field is 'type' not 'constraint_type')
operator: '<=',
threshold: 1.0, // Field is 'threshold' not 'limit'
// Source is required
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
enabled: true,
canvas_position: position,
};
case 'algorithm':
return {
name: 'Algorithm',
type: 'TPE',
budget: {
max_trials: 100,
},
canvas_position: position,
};
case 'surrogate':
return {
name: 'Surrogate',
enabled: false,
model_type: 'MLP',
min_trials: 20,
canvas_position: position,
};
}
}
@@ -162,6 +235,7 @@ function SpecRendererInner({
clearSelection,
updateNodePosition,
addNode,
updateNode,
addEdge,
removeEdge,
removeNode,
@@ -173,6 +247,170 @@ function SpecRendererInner({
const wsStudyId = enableWebSocket ? storeStudyId : null;
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
// Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization WebSocket stream for real-time updates
const {
status: optimizationStatus,
progress: wsProgress,
bestTrial: wsBestTrial,
recentTrials,
} = useOptimizationStream(studyId, {
autoReportErrors: true,
onTrialComplete: (trial) => {
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
},
onNewBest: (best) => {
console.log('[SpecRenderer] New best found:', best.value);
setShowResults(true); // Auto-show results when new best found
},
});
// Optimization execution state
const isRunning = optimizationStatus === 'running';
const [isStarting, setIsStarting] = useState(false);
const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// When connecting Extractor → Objective/Constraint and the extractor has multiple outputs,
// we prompt the user to choose which output_name to use.
const [pendingOutputSelect, setPendingOutputSelect] = useState<null | {
sourceId: string;
targetId: string;
outputNames: string[];
selected: string;
}>(null);
// Build trial history for sparklines (extract objective values from recent trials)
const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
for (const trial of recentTrials) {
// Map objective values - assumes single objective for now
if (trial.objective !== null) {
const key = 'primary';
if (!history[key]) history[key] = [];
history[key].push(trial.objective);
}
// Could also extract individual params/results for multi-objective
}
// Reverse so oldest is first (for sparkline)
for (const key of Object.keys(history)) {
history[key].reverse();
}
return history;
}, [recentTrials]);
// Build best trial data for node display
const bestTrial = useMemo((): {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
results: Record<string, number>;
} | null => {
if (!wsBestTrial) return null;
return {
trial_number: wsBestTrial.trial_number,
objective: wsBestTrial.value,
design_variables: wsBestTrial.params,
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
};
}, [wsBestTrial]);
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
// The hook handles: status updates, best trial updates, error reporting
// Validate the spec and show results in panel
const handleValidate = useCallback(() => {
if (!spec) return;
const result = validateSpec(spec);
setValidationData(result);
setValidationStatus(result.valid ? 'valid' : 'invalid');
// Auto-open validation panel if there are issues
if (!result.valid || result.warnings.length > 0) {
openPanel('validation');
}
return result;
}, [spec, setValidationData, openPanel]);
const handleRun = async () => {
if (!studyId || !spec) return;
// Validate before running
const validation = handleValidate();
if (!validation || !validation.valid) {
// Show validation panel with errors
return;
}
// Also do a quick sanity check
const { canRun, reason } = canRunOptimization(spec);
if (!canRun) {
addError({
type: 'config_error',
message: reason || 'Cannot run optimization',
recoverable: false,
suggestions: ['Check the validation panel for details'],
timestamp: Date.now(),
});
return;
}
setIsStarting(true);
try {
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to start');
}
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
setError(errorMessage);
// Also add to error panel for persistence
addError({
type: 'system_error',
message: errorMessage,
recoverable: true,
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
timestamp: Date.now(),
});
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
if (!studyId) return;
try {
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop');
}
// isRunning will update via WebSocket when optimization actually stops
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage);
addError({
type: 'system_error',
message: errorMessage,
recoverable: false,
suggestions: ['The optimization may still be running in the background'],
timestamp: Date.now(),
});
}
};
// Load spec on mount if studyId provided
useEffect(() => {
if (studyId) {
@@ -184,10 +422,143 @@ function SpecRendererInner({
}
}, [studyId, loadSpec, onStudyChange]);
// -------------------------------------------------------------------------
// Option A: Edge projection sync (source fields are truth)
// Keep canvas edges in sync when user edits objective/constraint source in panels.
// We only enforce Extractor -> Objective/Constraint wiring edges here.
// -------------------------------------------------------------------------
const isEdgeSyncingRef = useRef(false);
useEffect(() => {
if (!spec || !studyId) return;
if (isEdgeSyncingRef.current) return;
const current = spec.canvas?.edges || [];
// Compute desired extractor->objective/constraint edges from source fields
const desiredPairs = new Set<string>();
for (const obj of spec.objectives || []) {
const extractorId = obj.source?.extractor_id;
const outputName = obj.source?.output_name;
if (extractorId && outputName && extractorId !== '__UNSET__' && outputName !== '__UNSET__') {
desiredPairs.add(`${extractorId}__${obj.id}`);
}
}
for (const con of spec.constraints || []) {
const extractorId = con.source?.extractor_id;
const outputName = con.source?.output_name;
if (extractorId && outputName && extractorId !== '__UNSET__' && outputName !== '__UNSET__') {
desiredPairs.add(`${extractorId}__${con.id}`);
}
}
// Identify current wiring edges (ext_* -> obj_*/con_*)
const currentWiringPairs = new Set<string>();
for (const e of current) {
if (e.source?.startsWith('ext_') && (e.target?.startsWith('obj_') || e.target?.startsWith('con_'))) {
currentWiringPairs.add(`${e.source}__${e.target}`);
}
}
// Determine adds/removes
const toAdd: Array<{ source: string; target: string }> = [];
for (const key of desiredPairs) {
if (!currentWiringPairs.has(key)) {
const [source, target] = key.split('__');
toAdd.push({ source, target });
}
}
const toRemove: Array<{ source: string; target: string }> = [];
for (const key of currentWiringPairs) {
if (!desiredPairs.has(key)) {
const [source, target] = key.split('__');
toRemove.push({ source, target });
}
}
if (toAdd.length === 0 && toRemove.length === 0) return;
isEdgeSyncingRef.current = true;
(async () => {
try {
// Remove stale edges first
for (const e of toRemove) {
await removeEdge(e.source, e.target);
}
// Add missing edges
for (const e of toAdd) {
await addEdge(e.source, e.target);
}
} catch (err) {
console.error('[SpecRenderer] Edge projection sync failed:', err);
} finally {
// Small delay avoids re-entrancy storms when backend broadcasts updates
setTimeout(() => {
isEdgeSyncingRef.current = false;
}, 250);
}
})();
}, [spec, studyId, addEdge, removeEdge]);
// Convert spec to ReactFlow nodes
const nodes = useMemo(() => {
return specToNodes(spec);
}, [spec]);
const baseNodes = specToNodes(spec);
// Always map nodes to include history for sparklines (even if not showing results)
return baseNodes.map(node => {
// Create a mutable copy with explicit any type for dynamic property assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newData: any = { ...node.data };
// Add history for sparklines on objective nodes
if (node.type === 'objective') {
newData.history = trialHistory['primary'] || [];
}
// Map results to nodes when showing results
if (showResults && bestTrial) {
if (node.type === 'designVar' && newData.expressionName) {
const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
} else if (node.type === 'constraint') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
const op = newData.operator;
const threshold = newData.value;
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
}
} else if (node.type === 'extractor') {
const outputNames = newData.outputNames;
if (outputNames && outputNames.length > 0 && bestTrial.results) {
const firstOut = outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
}
}
return { ...node, data: newData };
});
}, [spec, showResults, bestTrial, trialHistory]);
// Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => {
@@ -208,12 +579,23 @@ function SpecRendererInner({
nodesRef.current = nodes;
}, [nodes]);
// Track local node state for smooth dragging
const [localNodes, setLocalNodes] = useState(nodes);
// Sync local nodes with spec-derived nodes when spec changes
useEffect(() => {
setLocalNodes(nodes);
}, [nodes]);
// Handle node position changes
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
if (!editable) return;
// Handle position changes
// Apply changes to local state for smooth dragging
setLocalNodes((nds) => applyNodeChanges(changes, nds));
// Handle position changes - save to spec when drag ends
for (const change of changes) {
if (change.type === 'position' && change.position && change.dragging === false) {
// Dragging ended - update spec
@@ -232,34 +614,111 @@ function SpecRendererInner({
(changes: EdgeChange[]) => {
if (!editable) return;
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
for (const change of changes) {
if (change.type === 'remove') {
// Find the edge being removed
const edge = edges.find((e) => e.id === change.id);
if (edge) {
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
if (!edge) continue;
const sourceType = classify(edge.source);
const targetType = classify(edge.target);
// First remove the visual edge
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
setError(err.message);
});
// Option A truth model: if we removed Extractor -> Objective/Constraint,
// clear the target's source to avoid stale runnable config.
if (sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
updateNode(edge.target, {
// Objective/constraint.source is required by schema.
// Use explicit UNSET placeholders so validation can catch it
// without risking accidental execution.
source: { extractor_id: '__UNSET__', output_name: '__UNSET__' },
}).catch((err) => {
console.error('Failed to clear source on node:', err);
setError(err.message);
});
}
}
}
},
[editable, edges, removeEdge, setError]
[editable, edges, removeEdge, setError, updateNode]
);
// Handle new connections
const onConnect = useCallback(
(connection: Connection) => {
async (connection: Connection) => {
if (!editable) return;
if (!connection.source || !connection.target) return;
addEdge(connection.source, connection.target).catch((err) => {
console.error('Failed to add edge:', err);
setError(err.message);
});
const sourceId = connection.source;
const targetId = connection.target;
// Helper: classify nodes by ID (synthetic vs spec-backed)
const classify = (id: string): string => {
if (id === 'model' || id === 'solver' || id === 'algorithm' || id === 'surrogate') return id;
const prefix = id.split('_')[0];
if (prefix === 'dv') return 'designVar';
if (prefix === 'ext') return 'extractor';
if (prefix === 'obj') return 'objective';
if (prefix === 'con') return 'constraint';
return 'unknown';
};
const sourceType = classify(sourceId);
const targetType = classify(targetId);
try {
// Option A truth model: objective/constraint source is the real linkage.
// When user connects Extractor -> Objective/Constraint, we must choose an output_name.
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
const ext = spec.extractors.find((e) => e.id === sourceId);
const outputNames = (ext?.outputs || []).map((o) => o.name).filter(Boolean);
// If extractor has multiple outputs, prompt the user.
if (outputNames.length > 1) {
const preferred = outputNames.includes('value') ? 'value' : outputNames[0];
setPendingOutputSelect({
sourceId,
targetId,
outputNames,
selected: preferred,
});
return;
}
// Single (or zero) output: choose deterministically.
const outputName = outputNames[0] || 'value';
// Persist edge + runnable source.
await addEdge(sourceId, targetId);
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
return;
}
// Default: just persist the visual edge.
await addEdge(sourceId, targetId);
} catch (err) {
console.error('Failed to add connection:', err);
setError(err instanceof Error ? err.message : 'Failed to add connection');
}
},
[editable, addEdge, setError]
[editable, addEdge, setError, spec, updateNode, setPendingOutputSelect]
);
// Handle node clicks for selection
@@ -353,6 +812,18 @@ function SpecRendererInner({
return;
}
// Check if this is a singleton type that already exists
if (isSingletonType(type)) {
const existingNode = localNodes.find(n => n.type === type);
if (existingNode) {
// Select the existing node instead of creating a duplicate
selectNode(existingNode.id);
// Show a toast notification would be nice here
console.log(`${type} already exists - selected existing node`);
return;
}
}
// Convert screen position to flow position
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX,
@@ -363,8 +834,19 @@ function SpecRendererInner({
const nodeData = getDefaultNodeData(type, position);
const specType = mapNodeTypeToSpecType(type);
// For structural types (model, solver, algorithm, surrogate), these are
// part of the spec structure rather than array items. Handle differently.
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
if (structuralTypes.includes(type)) {
// These nodes are derived from spec structure - they shouldn't be "added"
// They already exist if the spec has that section configured
console.log(`${type} is a structural node - configure via spec directly`);
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
return;
}
try {
const nodeId = await addNode(specType, nodeData);
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
// Select the newly created node
selectNode(nodeId);
} catch (err) {
@@ -372,9 +854,37 @@ function SpecRendererInner({
setError(err instanceof Error ? err.message : 'Failed to add node');
}
},
[editable, addNode, selectNode, setError]
[editable, addNode, selectNode, setError, localNodes]
);
// -------------------------------------------------------------------------
// Output selection modal handlers (Extractor → Objective/Constraint)
// -------------------------------------------------------------------------
const confirmOutputSelection = useCallback(async () => {
if (!pendingOutputSelect) return;
const { sourceId, targetId, selected } = pendingOutputSelect;
try {
// Persist edge + runnable source wiring
await addEdge(sourceId, targetId);
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: selected },
});
} catch (err) {
console.error('Failed to apply output selection:', err);
setError(err instanceof Error ? err.message : 'Failed to apply output selection');
} finally {
setPendingOutputSelect(null);
}
}, [pendingOutputSelect, addEdge, updateNode, setError]);
const cancelOutputSelection = useCallback(() => {
// User canceled: do not create the edge, do not update source
setPendingOutputSelect(null);
}, []);
// Loading state
if (showLoadingOverlay && isLoading && !spec) {
return (
@@ -457,14 +967,65 @@ function SpecRendererInner({
</div>
)}
{/* Output selection modal (Extractor → Objective/Constraint) */}
{pendingOutputSelect && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[520px] max-w-[90vw] bg-dark-850 border border-dark-600 rounded-xl shadow-2xl p-5">
<h3 className="text-white font-semibold text-lg">Select extractor output</h3>
<p className="text-sm text-dark-300 mt-1">
This extractor provides multiple outputs. Choose which output the target should use.
</p>
<div className="mt-4">
<label className="block text-sm font-medium text-dark-300 mb-1">Output</label>
<select
value={pendingOutputSelect.selected}
onChange={(e) =>
setPendingOutputSelect((prev) =>
prev ? { ...prev, selected: e.target.value } : prev
)
}
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors"
>
{pendingOutputSelect.outputNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<p className="text-xs text-dark-500 mt-2">
Tip: we default to <span className="text-dark-300 font-medium">value</span> when available.
</p>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={cancelOutputSelection}
className="px-4 py-2 bg-dark-700 text-dark-200 hover:bg-dark-600 rounded-lg border border-dark-600 transition-colors"
>
Cancel
</button>
<button
onClick={confirmOutputSelection}
className="px-4 py-2 bg-primary-600 text-white hover:bg-primary-500 rounded-lg border border-primary-500 transition-colors"
>
Connect
</button>
</div>
</div>
</div>
)}
<ReactFlow
nodes={nodes}
nodes={localNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={(instance) => {
reactFlowInstance.current = instance;
// Auto-fit view on init with padding
setTimeout(() => instance.fitView({ padding: 0.2, duration: 300 }), 100);
}}
onDragOver={onDragOver}
onDrop={onDrop}
@@ -473,6 +1034,7 @@ function SpecRendererInner({
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
deleteKeyCode={null} // We handle delete ourselves
nodesDraggable={editable}
nodesConnectable={editable}
@@ -488,10 +1050,113 @@ function SpecRendererInner({
/>
</ReactFlow>
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
{/* Results toggle */}
{bestTrial && (
<button
onClick={() => setShowResults(!showResults)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
showResults
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title={showResults ? "Hide Results" : "Show Best Trial Results"}
>
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
<span className="text-sm font-medium">Results</span>
</button>
)}
{/* Validate button - shows validation status */}
<button
onClick={handleValidate}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
validationStatus === 'valid'
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
: validationStatus === 'invalid'
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title="Validate spec before running"
>
{validationStatus === 'valid' ? (
<CheckCircle size={16} />
) : validationStatus === 'invalid' ? (
<AlertCircle size={16} />
) : (
<CheckCircle size={16} />
)}
<span className="text-sm font-medium">Validate</span>
</button>
{/* Run/Stop button */}
{isRunning ? (
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
>
<Square size={16} fill="currentColor" />
Stop
</button>
) : (
<button
onClick={handleRun}
disabled={isStarting || validationStatus === 'invalid'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
validationStatus === 'invalid'
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
>
{isStarting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} fill="currentColor" />
)}
Run
</button>
)}
</div>
{/* Study name badge */}
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div>
{/* Progress indicator when running */}
{isRunning && wsProgress && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
<ProgressRing
progress={wsProgress.percentage}
size={36}
strokeWidth={3}
color="#10b981"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Trial {wsProgress.current} / {wsProgress.total}
</span>
<span className="text-xs text-dark-400">
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
</span>
</div>
{wsBestTrial && (
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
<span className="text-xs text-dark-400">Best</span>
<span className="text-sm font-medium text-emerald-400">
{typeof wsBestTrial.value === 'number'
? wsBestTrial.value.toFixed(4)
: wsBestTrial.value}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
// Main Canvas Component
export { AtomizerCanvas } from './AtomizerCanvas';
export { SpecRenderer } from './SpecRenderer';
// Palette
export { NodePalette } from './palette/NodePalette';

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { ShieldAlert } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ConstraintNodeData } from '../../../lib/canvas/schema';
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
{data.name && data.operator && data.value !== undefined
? `${data.name} ${data.operator} ${data.value}`
: 'Set constraint'}

View File

@@ -0,0 +1,58 @@
/**
* CustomExtractorNode - Canvas node for custom Python extractors
*
* Displays custom extractors defined with inline Python code.
* Visually distinct from builtin extractors with a code icon.
*
* P3.11: Custom extractor UI component
*/
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Code2 } from 'lucide-react';
import { BaseNode } from './BaseNode';
export interface CustomExtractorNodeData {
type: 'customExtractor';
label: string;
configured: boolean;
extractorId?: string;
extractorName?: string;
functionName?: string;
functionSource?: string;
outputs?: Array<{ name: string; units?: string }>;
dependencies?: string[];
}
function CustomExtractorNodeComponent(props: NodeProps<CustomExtractorNodeData>) {
const { data } = props;
// Show validation status
const hasCode = !!data.functionSource?.trim();
const hasOutputs = (data.outputs?.length ?? 0) > 0;
const isConfigured = hasCode && hasOutputs;
return (
<BaseNode
{...props}
icon={<Code2 size={16} />}
iconColor={isConfigured ? 'text-violet-400' : 'text-dark-500'}
>
<div className="flex flex-col">
<span className={isConfigured ? 'text-white' : 'text-dark-400'}>
{data.extractorName || data.functionName || 'Custom Extractor'}
</span>
{!isConfigured && (
<span className="text-xs text-amber-400">Needs configuration</span>
)}
{isConfigured && data.outputs && (
<span className="text-xs text-dark-400">
{data.outputs.length} output{data.outputs.length !== 1 ? 's' : ''}
</span>
)}
</div>
</BaseNode>
);
}
export const CustomExtractorNode = memo(CustomExtractorNodeComponent);

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { SlidersHorizontal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { DesignVarNodeData } from '../../../lib/canvas/schema';
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
<ResultBadge value={data.resultValue} unit={data.unit} />
{data.expressionName ? (
<span className="font-mono">{data.expressionName}</span>
) : (

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { FlaskConical } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ExtractorNodeData } from '../../../lib/canvas/schema';
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
<ResultBadge value={data.resultValue} />
{data.extractorName || 'Select extractor'}
</BaseNode>
);

View File

@@ -2,13 +2,38 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
const { data } = props;
const hasHistory = data.history && data.history.length > 1;
return (
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
</span>
<ResultBadge value={data.resultValue} label="Best" />
</div>
{/* Convergence sparkline */}
{hasHistory && (
<div className="mt-1 -mb-1">
<ConvergenceSparkline
values={data.history!}
width={120}
height={20}
direction={data.direction || 'minimize'}
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
showBest={true}
/>
</div>
)}
</div>
</BaseNode>
);
}

View File

@@ -0,0 +1,39 @@
import { memo } from 'react';
interface ResultBadgeProps {
value: number | string | null | undefined;
unit?: string;
isFeasible?: boolean; // For constraints
label?: string;
}
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
if (value === null || value === undefined) return null;
const displayValue = typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
: value;
// Determine color based on feasibility (if provided)
let bgColor = 'bg-primary-500/20';
let textColor = 'text-primary-300';
let borderColor = 'border-primary-500/30';
if (isFeasible === true) {
bgColor = 'bg-emerald-500/20';
textColor = 'text-emerald-300';
borderColor = 'border-emerald-500/30';
} else if (isFeasible === false) {
bgColor = 'bg-red-500/20';
textColor = 'text-red-300';
borderColor = 'border-red-500/30';
}
return (
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
{label && <span className="opacity-70 mr-1">{label}:</span>}
<span className="font-bold">{displayValue}</span>
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
</div>
);
});

View File

@@ -1,14 +1,44 @@
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cpu } from 'lucide-react';
import { Cpu, Terminal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SolverNodeData } from '../../../lib/canvas/schema';
import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
// Human-readable engine names
const ENGINE_LABELS: Record<SolverEngine, string> = {
nxnastran: 'NX Nastran',
mscnastran: 'MSC Nastran',
python: 'Python Script',
abaqus: 'Abaqus',
ansys: 'ANSYS',
};
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
const { data } = props;
// Build display string: "Engine - SolutionType" or just one
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
const solverTypeLabel = data.solverType || null;
let displayText: string;
if (engineLabel && solverTypeLabel) {
displayText = `${engineLabel} (${solverTypeLabel})`;
} else if (engineLabel) {
displayText = engineLabel;
} else if (solverTypeLabel) {
displayText = solverTypeLabel;
} else {
displayText = 'Configure solver';
}
// Use Terminal icon for Python, Cpu for others
const icon = data.engine === 'python'
? <Terminal size={16} />
: <Cpu size={16} />;
return (
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
{data.solverType || 'Select solution'}
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
{displayText}
</BaseNode>
);
}

View File

@@ -54,6 +54,9 @@ export interface NodePaletteProps {
// Constants
// ============================================================================
/** Singleton node types - only one of each allowed on canvas */
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
export const PALETTE_ITEMS: PaletteItem[] = [
{
type: 'model',
@@ -61,15 +64,15 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: Box,
description: 'NX model file (.prt, .sim)',
color: 'text-blue-400',
canAdd: false, // Synthetic - derived from spec
canAdd: true, // Singleton - only one allowed
},
{
type: 'solver',
label: 'Solver',
icon: Cpu,
description: 'Nastran solution type',
description: 'Analysis solver config',
color: 'text-violet-400',
canAdd: false, // Synthetic - derived from model
canAdd: true, // Singleton - only one allowed
},
{
type: 'designVar',
@@ -109,7 +112,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: BrainCircuit,
description: 'Optimization method',
color: 'text-indigo-400',
canAdd: false, // Synthetic - derived from spec.optimization
canAdd: true, // Singleton - only one allowed
},
{
type: 'surrogate',
@@ -117,7 +120,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: Rocket,
description: 'Neural acceleration',
color: 'text-pink-400',
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
canAdd: true, // Singleton - only one allowed
},
];

View File

@@ -0,0 +1,360 @@
/**
* CustomExtractorPanel - Panel for editing custom Python extractors
*
* Provides a code editor for writing custom extraction functions,
* output definitions, and validation.
*
* P3.12: Custom extractor UI component
*/
import { useState, useCallback } from 'react';
import { X, Play, AlertCircle, CheckCircle, Plus, Trash2, HelpCircle } from 'lucide-react';
interface CustomExtractorOutput {
name: string;
units?: string;
description?: string;
}
interface CustomExtractorPanelProps {
isOpen: boolean;
onClose: () => void;
initialName?: string;
initialFunctionName?: string;
initialSource?: string;
initialOutputs?: CustomExtractorOutput[];
initialDependencies?: string[];
onSave: (data: {
name: string;
functionName: string;
source: string;
outputs: CustomExtractorOutput[];
dependencies: string[];
}) => void;
}
// Common styling classes
const inputClass =
'w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors';
const labelClass = 'block text-sm font-medium text-dark-300 mb-1';
// Default extractor template
const DEFAULT_SOURCE = `def extract(op2_path, bdf_path=None, params=None, working_dir=None):
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
bdf_path: Optional path to the BDF model file
params: Dictionary of current design parameters
working_dir: Path to the current trial directory
Returns:
Dictionary of output_name -> value
OR a single float value
OR a list/tuple of values (mapped to outputs in order)
"""
import numpy as np
from pyNastran.op2.op2 import OP2
# Load OP2 results
op2 = OP2(op2_path, debug=False)
# Example: compute custom metric
# ... your extraction logic here ...
result = 0.0
return {"custom_output": result}
`;
export function CustomExtractorPanel({
isOpen,
onClose,
initialName = '',
initialFunctionName = 'extract',
initialSource = DEFAULT_SOURCE,
initialOutputs = [{ name: 'custom_output', units: '' }],
initialDependencies = [],
onSave,
}: CustomExtractorPanelProps) {
const [name, setName] = useState(initialName);
const [functionName, setFunctionName] = useState(initialFunctionName);
const [source, setSource] = useState(initialSource);
const [outputs, setOutputs] = useState<CustomExtractorOutput[]>(initialOutputs);
const [dependencies] = useState<string[]>(initialDependencies);
const [validation, setValidation] = useState<{
valid: boolean;
errors: string[];
} | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showHelp, setShowHelp] = useState(false);
// Add a new output
const addOutput = useCallback(() => {
setOutputs((prev) => [...prev, { name: '', units: '' }]);
}, []);
// Remove an output
const removeOutput = useCallback((index: number) => {
setOutputs((prev) => prev.filter((_, i) => i !== index));
}, []);
// Update an output
const updateOutput = useCallback(
(index: number, field: keyof CustomExtractorOutput, value: string) => {
setOutputs((prev) =>
prev.map((out, i) => (i === index ? { ...out, [field]: value } : out))
);
},
[]
);
// Validate the code
const validateCode = useCallback(async () => {
setIsValidating(true);
setValidation(null);
try {
const response = await fetch('/api/spec/validate-extractor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
function_name: functionName,
source: source,
}),
});
const result = await response.json();
setValidation({
valid: result.valid,
errors: result.errors || [],
});
} catch (error) {
setValidation({
valid: false,
errors: ['Failed to validate: ' + (error instanceof Error ? error.message : 'Unknown error')],
});
} finally {
setIsValidating(false);
}
}, [functionName, source]);
// Handle save
const handleSave = useCallback(() => {
// Filter out empty outputs
const validOutputs = outputs.filter((o) => o.name.trim());
if (!name.trim()) {
setValidation({ valid: false, errors: ['Name is required'] });
return;
}
if (validOutputs.length === 0) {
setValidation({ valid: false, errors: ['At least one output is required'] });
return;
}
onSave({
name: name.trim(),
functionName: functionName.trim() || 'extract',
source,
outputs: validOutputs,
dependencies: dependencies.filter((d) => d.trim()),
});
onClose();
}, [name, functionName, source, outputs, dependencies, onSave, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-850 rounded-xl shadow-2xl w-[900px] max-h-[90vh] flex flex-col border border-dark-700">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-white">Custom Extractor</h2>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHelp(!showHelp)}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
title="Show help"
>
<HelpCircle size={20} />
</button>
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{/* Help Section */}
{showHelp && (
<div className="mb-4 p-4 bg-primary-900/20 border border-primary-700 rounded-lg">
<h3 className="text-sm font-semibold text-primary-400 mb-2">How Custom Extractors Work</h3>
<ul className="text-sm text-dark-300 space-y-1">
<li> Your function receives the path to OP2 results and optional BDF/params</li>
<li> Use pyNastran, numpy, scipy for data extraction and analysis</li>
<li> Return a dictionary mapping output names to numeric values</li>
<li> Outputs can be used as objectives or constraints in optimization</li>
<li> Code runs in a sandboxed environment (no file I/O beyond OP2/BDF)</li>
</ul>
</div>
)}
<div className="grid grid-cols-2 gap-6">
{/* Left Column - Basic Info & Outputs */}
<div className="space-y-4">
{/* Name */}
<div>
<label className={labelClass}>Extractor Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Custom Extractor"
className={inputClass}
/>
</div>
{/* Function Name */}
<div>
<label className={labelClass}>Function Name</label>
<input
type="text"
value={functionName}
onChange={(e) => setFunctionName(e.target.value)}
placeholder="extract"
className={`${inputClass} font-mono`}
/>
<p className="text-xs text-dark-500 mt-1">
Name of the Python function in your code
</p>
</div>
{/* Outputs */}
<div>
<label className={labelClass}>Outputs</label>
<div className="space-y-2">
{outputs.map((output, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={output.name}
onChange={(e) => updateOutput(index, 'name', e.target.value)}
placeholder="output_name"
className={`${inputClass} font-mono flex-1`}
/>
<input
type="text"
value={output.units || ''}
onChange={(e) => updateOutput(index, 'units', e.target.value)}
placeholder="units"
className={`${inputClass} w-24`}
/>
<button
onClick={() => removeOutput(index)}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors"
disabled={outputs.length === 1}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
onClick={addOutput}
className="flex items-center gap-1 text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
<Plus size={14} />
Add Output
</button>
</div>
</div>
{/* Validation Status */}
{validation && (
<div
className={`p-3 rounded-lg border ${
validation.valid
? 'bg-green-900/20 border-green-700'
: 'bg-red-900/20 border-red-700'
}`}
>
<div className="flex items-center gap-2">
{validation.valid ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<AlertCircle size={16} className="text-red-400" />
)}
<span
className={`text-sm font-medium ${
validation.valid ? 'text-green-400' : 'text-red-400'
}`}
>
{validation.valid ? 'Code is valid' : 'Validation failed'}
</span>
</div>
{validation.errors.length > 0 && (
<ul className="mt-2 text-sm text-red-300 space-y-1">
{validation.errors.map((err, i) => (
<li key={i}> {err}</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Right Column - Code Editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className={labelClass}>Python Code</label>
<button
onClick={validateCode}
disabled={isValidating}
className="flex items-center gap-1 px-3 py-1 bg-primary-600 hover:bg-primary-500
text-white text-sm rounded-lg transition-colors disabled:opacity-50"
>
<Play size={14} />
{isValidating ? 'Validating...' : 'Validate'}
</button>
</div>
<textarea
value={source}
onChange={(e) => {
setSource(e.target.value);
setValidation(null);
}}
className={`${inputClass} h-[400px] font-mono text-sm resize-none`}
spellCheck={false}
/>
<p className="text-xs text-dark-500">
Available modules: numpy, scipy, pyNastran, math, statistics
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
>
Save Extractor
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
/**
* ErrorPanel - Displays optimization errors with recovery options
*
* Shows errors that occurred during optimization with:
* - Error classification (NX crash, solver failure, etc.)
* - Recovery suggestions
* - Ability to dismiss individual errors
* - Support for multiple simultaneous errors
*/
import { useMemo } from 'react';
import {
X,
AlertTriangle,
AlertOctagon,
RefreshCw,
Minimize2,
Maximize2,
Trash2,
Bug,
Cpu,
FileWarning,
Settings,
Server,
} from 'lucide-react';
import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore';
interface ErrorPanelProps {
onClose: () => void;
onRetry?: (trial?: number) => void;
onSkipTrial?: (trial: number) => void;
}
export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) {
const panel = useErrorPanel();
const { minimizePanel, dismissError, clearErrors } = usePanelStore();
const sortedErrors = useMemo(() => {
return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp);
}, [panel.errors]);
if (!panel.open || panel.errors.length === 0) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('error')}
>
<AlertOctagon size={16} className="text-red-400" />
<span className="text-sm text-white font-medium">
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={18} className="text-red-400" />
<span className="font-medium text-white">
Optimization Errors ({panel.errors.length})
</span>
</div>
<div className="flex items-center gap-1">
{panel.errors.length > 1 && (
<button
onClick={clearErrors}
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Clear all errors"
>
<Trash2 size={14} />
</button>
)}
<button
onClick={() => minimizePanel('error')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{sortedErrors.map((error) => (
<ErrorItem
key={error.timestamp}
error={error}
onDismiss={() => dismissError(error.timestamp)}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
))}
</div>
</div>
);
}
// ============================================================================
// Error Item Component
// ============================================================================
interface ErrorItemProps {
error: OptimizationError;
onDismiss: () => void;
onRetry?: (trial?: number) => void;
onSkipTrial?: (trial: number) => void;
}
function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) {
const icon = getErrorIcon(error.type);
const typeLabel = getErrorTypeLabel(error.type);
const timeAgo = getTimeAgo(error.timestamp);
return (
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
{/* Error header */}
<div className="flex items-start gap-3 p-3">
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
{typeLabel}
</span>
{error.trial !== undefined && (
<span className="text-xs text-dark-500">
Trial #{error.trial}
</span>
)}
<span className="text-xs text-dark-600 ml-auto">
{timeAgo}
</span>
</div>
<p className="text-sm text-white">{error.message}</p>
{error.details && (
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
{error.details}
</p>
)}
</div>
<button
onClick={onDismiss}
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
title="Dismiss"
>
<X size={14} />
</button>
</div>
{/* Suggestions */}
{error.suggestions.length > 0 && (
<div className="px-3 pb-3">
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
<ul className="text-xs text-dark-300 space-y-1">
{error.suggestions.map((suggestion, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-dark-500">-</span>
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
)}
{/* Actions */}
{error.recoverable && (
<div className="flex items-center gap-2 px-3 pb-3">
{onRetry && (
<button
onClick={() => onRetry(error.trial)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
text-white text-xs font-medium rounded transition-colors"
>
<RefreshCw size={12} />
Retry{error.trial !== undefined ? ' Trial' : ''}
</button>
)}
{onSkipTrial && error.trial !== undefined && (
<button
onClick={() => onSkipTrial(error.trial!)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
text-dark-200 text-xs font-medium rounded transition-colors"
>
Skip Trial
</button>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Helper Functions
// ============================================================================
function getErrorIcon(type: OptimizationError['type']) {
switch (type) {
case 'nx_crash':
return <Cpu size={16} className="text-red-400" />;
case 'solver_fail':
return <AlertTriangle size={16} className="text-amber-400" />;
case 'extractor_error':
return <FileWarning size={16} className="text-orange-400" />;
case 'config_error':
return <Settings size={16} className="text-blue-400" />;
case 'system_error':
return <Server size={16} className="text-purple-400" />;
default:
return <Bug size={16} className="text-red-400" />;
}
}
function getErrorTypeLabel(type: OptimizationError['type']) {
switch (type) {
case 'nx_crash':
return 'NX Crash';
case 'solver_fail':
return 'Solver Failure';
case 'extractor_error':
return 'Extractor Error';
case 'config_error':
return 'Configuration Error';
case 'system_error':
return 'System Error';
default:
return 'Unknown Error';
}
}
function getTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export default ErrorPanel;

View File

@@ -0,0 +1,485 @@
/**
* FloatingIntrospectionPanel - Persistent introspection panel using store
*
* This is a wrapper around the existing IntrospectionPanel that:
* 1. Gets its state from usePanelStore instead of local state
* 2. Persists data when the panel is closed and reopened
* 3. Can be opened from anywhere without losing state
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
X,
Search,
RefreshCw,
Plus,
ChevronDown,
ChevronRight,
Cpu,
SlidersHorizontal,
Scale,
Minimize2,
Maximize2,
} from 'lucide-react';
import {
useIntrospectionPanel,
usePanelStore,
} from '../../../hooks/usePanelStore';
import { useSpecStore } from '../../../hooks/useSpecStore';
interface FloatingIntrospectionPanelProps {
onClose: () => void;
}
// Reuse types from original IntrospectionPanel
interface Expression {
name: string;
value: number;
rhs?: string;
min?: number;
max?: number;
unit?: string;
units?: string;
type: string;
source?: string;
}
interface ExpressionsResult {
user: Expression[];
internal: Expression[];
total_count: number;
user_count: number;
}
interface IntrospectionResult {
solver_type?: string;
expressions?: ExpressionsResult;
// Allow other properties from the API response
file_deps?: unknown[];
fea_results?: unknown[];
fem_mesh?: unknown;
sim_solutions?: unknown[];
sim_bcs?: unknown[];
mass_properties?: {
total_mass?: number;
center_of_gravity?: { x: number; y: number; z: number };
[key: string]: unknown;
};
}
interface ModelFileInfo {
name: string;
stem: string;
type: string;
description?: string;
size_kb: number;
has_cache: boolean;
}
interface ModelFilesResponse {
files: {
sim: ModelFileInfo[];
afm: ModelFileInfo[];
fem: ModelFileInfo[];
idealized: ModelFileInfo[];
prt: ModelFileInfo[];
};
all_files: ModelFileInfo[];
}
export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) {
const panel = useIntrospectionPanel();
const {
minimizePanel,
updateIntrospectionResult,
setIntrospectionLoading,
setIntrospectionError,
setIntrospectionFile,
} = usePanelStore();
const { addNode } = useSpecStore();
// Local UI state
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
);
const [searchTerm, setSearchTerm] = useState('');
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const data = panel.data;
const result = data?.result as IntrospectionResult | undefined;
const isLoading = data?.isLoading || false;
const error = data?.error as string | null;
// Fetch available files when studyId changes
const fetchAvailableFiles = useCallback(async () => {
if (!data?.studyId) return;
setIsLoadingFiles(true);
try {
const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`);
if (res.ok) {
const filesData = await res.json();
setModelFiles(filesData);
}
} catch (e) {
console.error('Failed to fetch model files:', e);
} finally {
setIsLoadingFiles(false);
}
}, [data?.studyId]);
// Run introspection
const runIntrospection = useCallback(async (fileName?: string) => {
if (!data?.filePath && !data?.studyId) return;
setIntrospectionLoading(true);
setIntrospectionError(null);
try {
let res;
if (data?.studyId) {
const endpoint = fileName
? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}`
: `/api/optimization/studies/${data.studyId}/nx/introspect`;
res = await fetch(endpoint);
} else {
res = await fetch('/api/nx/introspect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: data?.filePath }),
});
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || 'Introspection failed');
}
const responseData = await res.json();
updateIntrospectionResult(responseData.introspection || responseData);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
setIntrospectionError(msg);
console.error('Introspection error:', e);
}
}, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]);
// Fetch files list on mount
useEffect(() => {
fetchAvailableFiles();
}, [fetchAvailableFiles]);
// Run introspection when panel opens or selected file changes
useEffect(() => {
if (panel.open && data && !result && !isLoading) {
runIntrospection(data.selectedFile);
}
}, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newFile = e.target.value;
setIntrospectionFile(newFile);
runIntrospection(newFile);
};
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
// Handle both array format (old) and object format (new API)
const allExpressions: Expression[] = useMemo(() => {
if (!result?.expressions) return [];
if (Array.isArray(result.expressions)) {
return result.expressions as Expression[];
}
const exprObj = result.expressions as ExpressionsResult;
return [...(exprObj.user || []), ...(exprObj.internal || [])];
}, [result?.expressions]);
const filteredExpressions = allExpressions.filter((e) =>
e.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const addExpressionAsDesignVar = (expr: Expression) => {
const minValue = expr.min ?? expr.value * 0.5;
const maxValue = expr.max ?? expr.value * 1.5;
addNode('designVar', {
name: expr.name,
expression_name: expr.name,
type: 'continuous',
bounds: { min: minValue, max: maxValue },
baseline: expr.value,
units: expr.unit || expr.units,
enabled: true,
});
};
if (!panel.open) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('introspection')}
>
<Search size={16} className="text-primary-400" />
<span className="text-sm text-white font-medium">
Model Introspection
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Search size={16} className="text-primary-400" />
<span className="font-medium text-white text-sm">
Model Introspection
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => runIntrospection(data?.selectedFile)}
disabled={isLoading}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => minimizePanel('introspection')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* File Selector + Search */}
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
<select
value={data?.selectedFile || ''}
onChange={handleFileChange}
disabled={isLoading || isLoadingFiles}
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white focus:outline-none focus:border-primary-500
disabled:opacity-50"
>
<option value="">Default (Assembly)</option>
{modelFiles.files.sim.length > 0 && (
<optgroup label="Simulation (.sim)">
{modelFiles.files.sim.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.afm.length > 0 && (
<optgroup label="Assembly FEM (.afm)">
{modelFiles.files.afm.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.fem.length > 0 && (
<optgroup label="FEM (.fem)">
{modelFiles.files.fem.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.prt.length > 0 && (
<optgroup label="Geometry (.prt)">
{modelFiles.files.prt.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.idealized.length > 0 && (
<optgroup label="Idealized (_i.prt)">
{modelFiles.files.idealized.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
</select>
{isLoadingFiles && (
<RefreshCw size={12} className="animate-spin text-dark-400" />
)}
</div>
)}
<input
type="text"
placeholder="Filter expressions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Analyzing model...
</div>
) : error ? (
<div className="p-4 text-red-400 text-sm">{error}</div>
) : result ? (
<div className="p-2 space-y-2">
{/* Solver Type */}
{result.solver_type && (
<div className="p-2 bg-dark-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Cpu size={14} className="text-violet-400" />
<span className="text-dark-300">Solver:</span>
<span className="text-white font-medium">{result.solver_type as string}</span>
</div>
</div>
)}
{/* Expressions Section */}
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('expressions')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<SlidersHorizontal size={14} className="text-emerald-400" />
<span className="text-sm font-medium text-white">
Expressions ({filteredExpressions.length})
</span>
</div>
{expandedSections.has('expressions') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('expressions') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{filteredExpressions.length === 0 ? (
<p className="text-xs text-dark-500 text-center py-2">
No expressions found
</p>
) : (
filteredExpressions.map((expr) => (
<div
key={expr.name}
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{expr.name}</p>
<p className="text-xs text-dark-500">
{expr.value} {expr.units || expr.unit || ''}
{expr.source === 'inferred' && (
<span className="ml-1 text-amber-500">(inferred)</span>
)}
</p>
</div>
<button
onClick={() => addExpressionAsDesignVar(expr)}
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
opacity-0 group-hover:opacity-100 transition-all"
title="Add as Design Variable"
>
<Plus size={14} />
</button>
</div>
))
)}
</div>
)}
</div>
{/* Mass Properties Section */}
{result.mass_properties && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('mass')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Scale size={14} className="text-blue-400" />
<span className="text-sm font-medium text-white">Mass Properties</span>
</div>
{expandedSections.has('mass') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('mass') && (
<div className="p-2 space-y-1">
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Mass</span>
<span className="text-white font-mono">
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
</span>
</div>
)}
</div>
)}
</div>
)}
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
</div>
) : (
<div className="p-4 text-center text-dark-500 text-sm">
Click refresh to analyze the model
</div>
)}
</div>
</div>
);
}
export default FloatingIntrospectionPanel;

View File

@@ -17,8 +17,8 @@ import {
useSelectedNodeId,
useSelectedNode,
} from '../../../hooks/useSpecStore';
import { usePanelStore } from '../../../hooks/usePanelStore';
import { FileBrowser } from './FileBrowser';
import { IntrospectionPanel } from './IntrospectionPanel';
import {
DesignVariable,
Extractor,
@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
const { updateNode, removeNode, clearSelection } = useSpecStore();
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showIntrospection, setShowIntrospection] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -249,15 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
fileTypes={['.sim', '.prt', '.fem', '.afem']}
/>
{/* Introspection Panel */}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
</div>
);
}
@@ -271,7 +262,16 @@ interface SpecConfigProps {
}
function ModelNodeConfig({ spec }: SpecConfigProps) {
const [showIntrospection, setShowIntrospection] = useState(false);
const { setIntrospectionData, openPanel } = usePanelStore();
const handleOpenIntrospection = () => {
// Set up introspection data and open the panel
setIntrospectionData({
filePath: spec.model.sim?.path || '',
studyId: useSpecStore.getState().studyId || undefined,
});
openPanel('introspection');
};
return (
<>
@@ -299,7 +299,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
{spec.model.sim?.path && (
<button
onClick={() => setShowIntrospection(true)}
onClick={handleOpenIntrospection}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
@@ -309,31 +309,112 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
</button>
)}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
</>
);
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
const { patchSpec } = useSpecStore();
const [isUpdating, setIsUpdating] = useState(false);
const engine = spec.model.sim?.engine || 'nxnastran';
const solutionType = spec.model.sim?.solution_type || 'SOL101';
const scriptPath = spec.model.sim?.script_path || '';
const isPython = engine === 'python';
const handleEngineChange = async (newEngine: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.engine', newEngine);
} catch (err) {
console.error('Failed to update engine:', err);
} finally {
setIsUpdating(false);
}
};
const handleSolutionTypeChange = async (newType: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.solution_type', newType);
} catch (err) {
console.error('Failed to update solution type:', err);
} finally {
setIsUpdating(false);
}
};
const handleScriptPathChange = async (newPath: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.script_path', newPath);
} catch (err) {
console.error('Failed to update script path:', err);
} finally {
setIsUpdating(false);
}
};
return (
<div>
<label className={labelClass}>Solution Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not configured'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
title="Solver type is determined by the model file."
/>
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
</div>
<>
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
<div>
<label className={labelClass}>Solver Engine</label>
<select
value={engine}
onChange={(e) => handleEngineChange(e.target.value)}
className={selectClass}
>
<option value="nxnastran">NX Nastran (built-in)</option>
<option value="mscnastran">MSC Nastran (external)</option>
<option value="python">Python Script</option>
<option value="abaqus" disabled>Abaqus (coming soon)</option>
<option value="ansys" disabled>ANSYS (coming soon)</option>
</select>
<p className="text-xs text-dark-500 mt-1">
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
</p>
</div>
{!isPython && (
<div>
<label className={labelClass}>Solution Type</label>
<select
value={solutionType}
onChange={(e) => handleSolutionTypeChange(e.target.value)}
className={selectClass}
>
<option value="SOL101">SOL101 - Linear Statics</option>
<option value="SOL103">SOL103 - Normal Modes</option>
<option value="SOL105">SOL105 - Buckling</option>
<option value="SOL106">SOL106 - Nonlinear Statics</option>
<option value="SOL111">SOL111 - Modal Frequency Response</option>
<option value="SOL112">SOL112 - Modal Transient Response</option>
<option value="SOL200">SOL200 - Design Optimization</option>
</select>
</div>
)}
{isPython && (
<div>
<label className={labelClass}>Script Path</label>
<input
type="text"
value={scriptPath}
onChange={(e) => handleScriptPathChange(e.target.value)}
placeholder="path/to/solver_script.py"
className={`${inputClass} font-mono text-sm`}
/>
<p className="text-xs text-dark-500 mt-1">
Python script must define solve(params) function
</p>
</div>
)}
</>
);
}
@@ -694,38 +775,21 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
{showCodeEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-900">
<div className="flex items-center gap-3">
<FileCode size={18} className="text-violet-400" />
<span className="font-medium text-white">Custom Extractor: {node.name}</span>
<span className="text-xs text-dark-500 bg-dark-800 px-2 py-0.5 rounded">.py</span>
</div>
<button
onClick={() => setShowCodeEditor(false)}
className="p-1.5 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{/* Code Editor */}
<div className="flex-1">
<CodeEditorPanel
initialCode={currentCode}
extractorName={node.name}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={false}
height="100%"
studyId={studyId || undefined}
/>
</div>
{/* Code Editor with built-in header containing toolbar buttons */}
<CodeEditorPanel
initialCode={currentCode}
extractorName={`Custom Extractor: ${node.name}`}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={true}
height="100%"
studyId={studyId || undefined}
/>
</div>
</div>
)}
@@ -756,6 +820,34 @@ interface ObjectiveNodeConfigProps {
}
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
// Reset output_name to a sensible default when extractor changes
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
@@ -768,6 +860,45 @@ function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div>
<label className={labelClass}>Direction</label>
<select
@@ -813,6 +944,33 @@ interface ConstraintNodeConfigProps {
}
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
@@ -825,6 +983,45 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Type</label>
@@ -833,24 +1030,37 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="less_than">&lt; Less than</option>
<option value="less_equal">&lt;= Less or equal</option>
<option value="greater_than">&gt; Greater than</option>
<option value="greater_equal">&gt;= Greater or equal</option>
<option value="equal">= Equal</option>
<option value="hard">Hard</option>
<option value="soft">Soft</option>
</select>
<p className="text-xs text-dark-500 mt-1">Spec type (hard/soft). Operator is set below.</p>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
<label className={labelClass}>Operator</label>
<select
value={node.operator}
onChange={(e) => onChange('operator', e.target.value)}
className={selectClass}
>
<option value="<=">&lt;=</option>
<option value="<">&lt;</option>
<option value=">=">&gt;=</option>
<option value=">">&gt;</option>
<option value="==">==</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,207 @@
/**
* PanelContainer - Orchestrates all floating panels in the canvas view
*
* This component renders floating panels (Introspection, Validation, Error, Results)
* in a portal, positioned absolutely within the canvas area.
*
* Features:
* - Draggable panels
* - Z-index management (click to bring to front)
* - Keyboard shortcuts (Escape to close all)
* - Position persistence via usePanelStore
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
usePanelStore,
useIntrospectionPanel,
useValidationPanel,
useErrorPanel,
useResultsPanel,
PanelPosition,
} from '../../../hooks/usePanelStore';
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
import { FloatingValidationPanel } from './ValidationPanel';
import { ErrorPanel } from './ErrorPanel';
import { ResultsPanel } from './ResultsPanel';
interface PanelContainerProps {
/** Container element to render panels into (defaults to document.body) */
container?: HTMLElement;
/** Callback when retry is requested from error panel */
onRetry?: (trial?: number) => void;
/** Callback when skip trial is requested */
onSkipTrial?: (trial: number) => void;
}
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
const introspectionPanel = useIntrospectionPanel();
const validationPanel = useValidationPanel();
const errorPanel = useErrorPanel();
const resultsPanel = useResultsPanel();
// Track which panel is on top (for z-index)
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
// Dragging state
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
// Escape key to close all panels
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeAllPanels();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeAllPanels]);
// Mouse move handler for dragging
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragRef.current) return;
const { panel, offset } = dragRef.current;
const newPosition: PanelPosition = {
x: e.clientX - offset.x,
y: e.clientY - offset.y,
};
// Clamp to viewport
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
setPanelPosition(panel, newPosition);
};
const handleMouseUp = () => {
dragRef.current = null;
setDragging(null);
};
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, setPanelPosition]);
// Start dragging a panel
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
const offset = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
dragRef.current = { panel, offset };
setDragging({ panel, offset });
setTopPanel(panel);
}, []);
// Click to bring panel to front
const handlePanelClick = useCallback((panel: PanelName) => {
setTopPanel(panel);
}, []);
// Get z-index for a panel
const getZIndex = (panel: PanelName) => {
const baseZ = 100;
if (panel === topPanel) return baseZ + 10;
return baseZ;
};
// Render a draggable wrapper
const renderDraggable = (
panel: PanelName,
position: PanelPosition,
isOpen: boolean,
children: React.ReactNode
) => {
if (!isOpen) return null;
return (
<div
key={panel}
className="fixed select-none"
style={{
left: position.x,
top: position.y,
zIndex: getZIndex(panel),
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
}}
onClick={() => handlePanelClick(panel)}
>
{/* Drag handle - the header area */}
<div
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleDragStart(panel, e, position)}
style={{ zIndex: 1 }}
/>
{/* Panel content */}
<div className="relative" style={{ zIndex: 0 }}>
{children}
</div>
</div>
);
};
// Determine what to render
const panels = (
<>
{/* Introspection Panel */}
{renderDraggable(
'introspection',
introspectionPanel.position || { x: 100, y: 100 },
introspectionPanel.open,
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
)}
{/* Validation Panel */}
{renderDraggable(
'validation',
validationPanel.position || { x: 150, y: 150 },
validationPanel.open,
<FloatingValidationPanel onClose={() => closePanel('validation')} />
)}
{/* Error Panel */}
{renderDraggable(
'error',
errorPanel.position || { x: 200, y: 100 },
errorPanel.open,
<ErrorPanel
onClose={() => closePanel('error')}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
)}
{/* Results Panel */}
{renderDraggable(
'results',
resultsPanel.position || { x: 250, y: 150 },
resultsPanel.open,
<ResultsPanel onClose={() => closePanel('results')} />
)}
</>
);
// Use portal if container specified, otherwise render in place
if (container) {
return createPortal(panels, container);
}
return panels;
}
export default PanelContainer;

View File

@@ -0,0 +1,179 @@
/**
* ResultsPanel - Shows detailed trial results
*
* Displays the parameters, objectives, and constraints for a specific trial.
* Can be opened by clicking on result badges on nodes.
*/
import {
X,
Minimize2,
Maximize2,
CheckCircle,
XCircle,
Trophy,
SlidersHorizontal,
Target,
AlertTriangle,
Clock,
} from 'lucide-react';
import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore';
interface ResultsPanelProps {
onClose: () => void;
}
export function ResultsPanel({ onClose }: ResultsPanelProps) {
const panel = useResultsPanel();
const { minimizePanel } = usePanelStore();
const data = panel.data;
if (!panel.open || !data) return null;
const timestamp = new Date(data.timestamp).toLocaleTimeString();
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('results')}
>
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="text-sm text-white font-medium">
Trial #{data.trialNumber}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="font-medium text-white">
Trial #{data.trialNumber}
</span>
{data.isBest && (
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
Best
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('results')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Status */}
<div className="flex items-center gap-3">
{data.isFeasible ? (
<div className="flex items-center gap-1.5 text-green-400">
<CheckCircle size={16} />
<span className="text-sm font-medium">Feasible</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-400">
<XCircle size={16} />
<span className="text-sm font-medium">Infeasible</span>
</div>
)}
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
<Clock size={14} />
<span className="text-xs">{timestamp}</span>
</div>
</div>
{/* Parameters */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<SlidersHorizontal size={12} />
Parameters
</h4>
<div className="space-y-1">
{Object.entries(data.params).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-white font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Objectives */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Target size={12} />
Objectives
</h4>
<div className="space-y-1">
{Object.entries(data.objectives).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Constraints (if any) */}
{data.constraints && Object.keys(data.constraints).length > 0 && (
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<AlertTriangle size={12} />
Constraints
</h4>
<div className="space-y-1">
{Object.entries(data.constraints).map(([name, constraint]) => (
<div
key={name}
className={`flex justify-between p-2 rounded text-sm ${
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
}`}
>
<span className="text-dark-300 flex items-center gap-1.5">
{constraint.feasible ? (
<CheckCircle size={12} className="text-green-400" />
) : (
<XCircle size={12} className="text-red-400" />
)}
{name}
</span>
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
{formatValue(constraint.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function formatValue(value: number): string {
if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) {
return value.toExponential(3);
}
return value.toFixed(4).replace(/\.?0+$/, '');
}
export default ResultsPanel;

View File

@@ -1,10 +1,41 @@
/**
* ValidationPanel - Displays spec validation errors and warnings
*
* Shows a list of validation issues that need to be fixed before
* running an optimization. Supports auto-navigation to problematic nodes.
*
* Can be used in two modes:
* 1. Legacy mode: Pass validation prop directly (for backward compatibility)
* 2. Store mode: Uses usePanelStore for persistent state
*/
import { useMemo } from 'react';
import {
X,
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronRight,
Minimize2,
Maximize2,
} from 'lucide-react';
import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore';
import { useSpecStore } from '../../../hooks/useSpecStore';
import { ValidationResult } from '../../../lib/canvas/validation';
interface ValidationPanelProps {
// ============================================================================
// Legacy Props Interface (for backward compatibility)
// ============================================================================
interface LegacyValidationPanelProps {
validation: ValidationResult;
}
export function ValidationPanel({ validation }: ValidationPanelProps) {
/**
* Legacy ValidationPanel - Inline display for canvas overlay
* Kept for backward compatibility with AtomizerCanvas
*/
export function ValidationPanel({ validation }: LegacyValidationPanelProps) {
return (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
{validation.errors.length > 0 && (
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
</div>
);
}
// ============================================================================
// New Floating Panel (uses store)
// ============================================================================
interface FloatingValidationPanelProps {
onClose: () => void;
}
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
const panel = useValidationPanel();
const { minimizePanel } = usePanelStore();
const { selectNode } = useSpecStore();
const { errors, warnings, valid } = useMemo(() => {
if (!panel.data) {
return { errors: [], warnings: [], valid: true };
}
return {
errors: panel.data.errors || [],
warnings: panel.data.warnings || [],
valid: panel.data.valid,
};
}, [panel.data]);
const handleNavigateToNode = (nodeId?: string) => {
if (nodeId) {
selectNode(nodeId);
}
};
if (!panel.open) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('validation')}
>
{valid ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<AlertCircle size={16} className="text-red-400" />
)}
<span className="text-sm text-white font-medium">
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
{valid ? (
<CheckCircle size={18} className="text-green-400" />
) : (
<AlertCircle size={18} className="text-red-400" />
)}
<span className="font-medium text-white">
{valid ? 'Validation Passed' : 'Validation Issues'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('validation')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{valid && errors.length === 0 && warnings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle size={40} className="text-green-400 mb-3" />
<p className="text-white font-medium">All checks passed!</p>
<p className="text-sm text-dark-400 mt-1">
Your spec is ready to run.
</p>
</div>
) : (
<>
{/* Errors */}
{errors.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
<AlertCircle size={12} />
Errors ({errors.length})
</h4>
{errors.map((error, idx) => (
<ValidationItem
key={`error-${idx}`}
item={error}
severity="error"
onNavigate={() => handleNavigateToNode(error.nodeId)}
/>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 mt-4">
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
<AlertTriangle size={12} />
Warnings ({warnings.length})
</h4>
{warnings.map((warning, idx) => (
<ValidationItem
key={`warning-${idx}`}
item={warning}
severity="warning"
onNavigate={() => handleNavigateToNode(warning.nodeId)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Footer */}
{!valid && (
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
<p className="text-xs text-dark-400">
Fix all errors before running the optimization.
Warnings can be ignored but may cause issues.
</p>
</div>
)}
</div>
);
}
// ============================================================================
// Validation Item Component
// ============================================================================
interface ValidationItemProps {
item: StoreValidationError;
severity: 'error' | 'warning';
onNavigate: () => void;
}
function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) {
const isError = severity === 'error';
const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10';
const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30';
const iconColor = isError ? 'text-red-400' : 'text-amber-400';
return (
<div
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
onClick={onNavigate}
>
<div className="flex items-start gap-2">
{isError ? (
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
) : (
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.message}</p>
{item.path && (
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
)}
{item.suggestion && (
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
)}
</div>
{item.nodeId && (
<ChevronRight
size={16}
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
/>
)}
</div>
</div>
);
}
export default ValidationPanel;

View File

@@ -0,0 +1,240 @@
/**
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
*
* Displays the last N trial values as a mini line chart.
* Used on ObjectiveNode to show convergence trend.
*/
import { useMemo } from 'react';
interface ConvergenceSparklineProps {
/** Array of values (most recent last) */
values: number[];
/** Width in pixels */
width?: number;
/** Height in pixels */
height?: number;
/** Line color */
color?: string;
/** Best value line color */
bestColor?: string;
/** Whether to show the best value line */
showBest?: boolean;
/** Direction: minimize shows lower as better, maximize shows higher as better */
direction?: 'minimize' | 'maximize';
/** Show dots at each point */
showDots?: boolean;
/** Number of points to display */
maxPoints?: number;
}
export function ConvergenceSparkline({
values,
width = 80,
height = 24,
color = '#60a5fa',
bestColor = '#34d399',
showBest = true,
direction = 'minimize',
showDots = false,
maxPoints = 20,
}: ConvergenceSparklineProps) {
const { path, bestY, points } = useMemo(() => {
if (!values || values.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Take last N points
const data = values.slice(-maxPoints);
if (data.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Calculate bounds with padding
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const range = maxVal - minVal || 1;
const padding = range * 0.1;
const yMin = minVal - padding;
const yMax = maxVal + padding;
const yRange = yMax - yMin;
// Calculate best value
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
// Map values to SVG coordinates
const xStep = width / Math.max(data.length - 1, 1);
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
// Build path
const points = data.map((v, i) => ({
x: i * xStep,
y: mapY(v),
value: v,
}));
const pathParts = points.map((p, i) =>
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
);
return {
path: pathParts.join(' '),
bestY: mapY(bestVal),
points,
minVal,
maxVal,
};
}, [values, width, height, maxPoints, direction]);
if (!values || values.length === 0) {
return (
<div
className="flex items-center justify-center text-dark-500 text-xs"
style={{ width, height }}
>
No data
</div>
);
}
return (
<svg
width={width}
height={height}
className="overflow-visible"
viewBox={`0 0 ${width} ${height}`}
>
{/* Best value line */}
{showBest && bestY !== null && (
<line
x1={0}
y1={bestY}
x2={width}
y2={bestY}
stroke={bestColor}
strokeWidth={1}
strokeDasharray="2,2"
opacity={0.5}
/>
)}
{/* Main line */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Gradient fill under the line */}
<defs>
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{points.length > 1 && (
<path
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
fill="url(#sparkline-gradient)"
/>
)}
{/* Dots at each point */}
{showDots && points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={2}
fill={color}
/>
))}
{/* Last point highlight */}
{points.length > 0 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r={3}
fill={color}
stroke="white"
strokeWidth={1}
/>
)}
</svg>
);
}
/**
* ProgressRing - Circular progress indicator
*/
interface ProgressRingProps {
/** Progress percentage (0-100) */
progress: number;
/** Size in pixels */
size?: number;
/** Stroke width */
strokeWidth?: number;
/** Progress color */
color?: string;
/** Background color */
bgColor?: string;
/** Show percentage text */
showText?: boolean;
}
export function ProgressRing({
progress,
size = 32,
strokeWidth = 3,
color = '#60a5fa',
bgColor = '#374151',
showText = true,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={bgColor}
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
{showText && (
<span
className="absolute text-xs font-medium"
style={{ color, fontSize: size * 0.25 }}
>
{Math.round(progress)}%
</span>
)}
</div>
);
}
export default ConvergenceSparkline;

View File

@@ -5,7 +5,7 @@ import { ToolCallCard, ToolCall } from './ToolCallCard';
export interface Message {
id: string;
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
isStreaming?: boolean;
@@ -18,6 +18,18 @@ interface ChatMessageProps {
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
const isAssistant = message.role === 'assistant';
const isSystem = message.role === 'system';
// System messages are displayed centered with special styling
if (isSystem) {
return (
<div className="flex justify-center my-2">
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
{message.content}
</div>
</div>
);
}
return (
<div

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, useMemo } from 'react';
import {
MessageSquare,
ChevronRight,
@@ -13,8 +13,10 @@ import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { ThinkingIndicator } from './ThinkingIndicator';
import { ModeToggle } from './ModeToggle';
import { useChat } from '../../hooks/useChat';
import { useChat, CanvasState, CanvasModification } from '../../hooks/useChat';
import { useStudy } from '../../context/StudyContext';
import { useCanvasStore } from '../../hooks/useCanvasStore';
import { NodeType } from '../../lib/canvas/schema';
interface ChatPaneProps {
isOpen: boolean;
@@ -31,6 +33,76 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
const messagesEndRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Get canvas state and modification functions from the store
const { nodes, edges, addNode, updateNodeData, selectNode, deleteSelected } = useCanvasStore();
// Build canvas state for chat context
const canvasState: CanvasState | null = useMemo(() => {
if (nodes.length === 0) return null;
return {
nodes: nodes.map(n => ({
id: n.id,
type: n.type,
data: n.data,
position: n.position,
})),
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
})),
studyName: selectedStudy?.name || selectedStudy?.id,
};
}, [nodes, edges, selectedStudy]);
// Track position offset for multiple node additions
const nodeAddCountRef = useRef(0);
// Handle canvas modifications from the assistant
const handleCanvasModification = React.useCallback((modification: CanvasModification) => {
console.log('Canvas modification from assistant:', modification);
switch (modification.action) {
case 'add_node':
if (modification.nodeType) {
const nodeType = modification.nodeType as NodeType;
// Calculate position: offset each new node so they don't stack
const basePosition = modification.position || { x: 100, y: 100 };
const offset = nodeAddCountRef.current * 120;
const position = {
x: basePosition.x,
y: basePosition.y + offset,
};
nodeAddCountRef.current += 1;
// Reset counter after a delay (for batch operations)
setTimeout(() => { nodeAddCountRef.current = 0; }, 2000);
addNode(nodeType, position, modification.data);
console.log(`Added ${nodeType} node at position:`, position);
}
break;
case 'update_node':
if (modification.nodeId && modification.data) {
updateNodeData(modification.nodeId, modification.data);
}
break;
case 'remove_node':
if (modification.nodeId) {
selectNode(modification.nodeId);
deleteSelected();
}
break;
// Edge operations would need additional store methods
case 'add_edge':
case 'remove_edge':
console.warn('Edge modification not yet implemented:', modification);
break;
}
}, [addNode, updateNodeData, selectNode, deleteSelected]);
const {
messages,
isThinking,
@@ -41,22 +113,38 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
sendMessage,
clearMessages,
switchMode,
updateCanvasState,
} = useChat({
studyId: selectedStudy?.id,
mode: 'user',
useWebSocket: true,
canvasState,
onError: (err) => console.error('Chat error:', err),
onCanvasModification: handleCanvasModification,
});
// Keep canvas state synced with chat
useEffect(() => {
updateCanvasState(canvasState);
}, [canvasState, updateCanvasState]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isThinking]);
// Welcome message based on study context
const welcomeMessage = selectedStudy
? `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`
: 'Select a study to get started, or ask me to help you create a new one.';
// Welcome message based on study and canvas context
const welcomeMessage = useMemo(() => {
if (selectedStudy) {
return `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`;
}
if (nodes.length > 0) {
const dvCount = nodes.filter(n => n.type === 'designVar').length;
const objCount = nodes.filter(n => n.type === 'objective').length;
return `I can see your canvas with ${dvCount} design variables and ${objCount} objectives. Ask me to analyze, validate, or create a study from this setup.`;
}
return 'Select a study to get started, or build an optimization in the Canvas Builder.';
}, [selectedStudy, nodes]);
// Collapsed state - just show toggle button
if (!isOpen) {

View File

@@ -30,22 +30,25 @@ interface ToolCallCardProps {
}
// Map tool names to friendly labels and icons
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color?: string }> = {
// Study tools
list_studies: { label: 'Listing Studies', icon: Database },
get_study_status: { label: 'Getting Status', icon: FileSearch },
create_study: { label: 'Creating Study', icon: Settings },
create_study: { label: 'Creating Study', icon: Settings, color: 'text-green-400' },
// Optimization tools
run_optimization: { label: 'Starting Optimization', icon: Play },
run_optimization: { label: 'Starting Optimization', icon: Play, color: 'text-blue-400' },
stop_optimization: { label: 'Stopping Optimization', icon: XCircle },
get_optimization_status: { label: 'Checking Progress', icon: BarChart2 },
// Analysis tools
get_trial_data: { label: 'Querying Trials', icon: Database },
query_trials: { label: 'Querying Trials', icon: Database },
get_trial_details: { label: 'Getting Trial Details', icon: FileSearch },
analyze_convergence: { label: 'Analyzing Convergence', icon: BarChart2 },
compare_trials: { label: 'Comparing Trials', icon: BarChart2 },
get_best_design: { label: 'Getting Best Design', icon: CheckCircle },
get_optimization_summary: { label: 'Getting Summary', icon: BarChart2 },
// Reporting tools
generate_report: { label: 'Generating Report', icon: FileText },
@@ -56,6 +59,25 @@ const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ cla
recommend_method: { label: 'Recommending Method', icon: Settings },
query_extractors: { label: 'Listing Extractors', icon: Database },
// Config tools (read)
read_study_config: { label: 'Reading Config', icon: FileSearch },
read_study_readme: { label: 'Reading README', icon: FileText },
// === WRITE TOOLS (Power Mode) ===
add_design_variable: { label: 'Adding Design Variable', icon: Settings, color: 'text-amber-400' },
add_extractor: { label: 'Adding Extractor', icon: Settings, color: 'text-amber-400' },
add_objective: { label: 'Adding Objective', icon: Settings, color: 'text-amber-400' },
add_constraint: { label: 'Adding Constraint', icon: Settings, color: 'text-amber-400' },
update_spec_field: { label: 'Updating Field', icon: Settings, color: 'text-amber-400' },
remove_node: { label: 'Removing Node', icon: XCircle, color: 'text-red-400' },
// === INTERVIEW TOOLS ===
start_interview: { label: 'Starting Interview', icon: HelpCircle, color: 'text-purple-400' },
interview_record: { label: 'Recording Answer', icon: CheckCircle, color: 'text-purple-400' },
interview_advance: { label: 'Advancing Interview', icon: Play, color: 'text-purple-400' },
interview_status: { label: 'Checking Progress', icon: BarChart2, color: 'text-purple-400' },
interview_finalize: { label: 'Creating Study', icon: CheckCircle, color: 'text-green-400' },
// Admin tools (power mode)
edit_file: { label: 'Editing File', icon: FileText },
create_file: { label: 'Creating File', icon: FileText },
@@ -104,7 +126,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
)}
{/* Tool icon */}
<Icon className="w-4 h-4 text-dark-400 flex-shrink-0" />
<Icon className={`w-4 h-4 flex-shrink-0 ${info.color || 'text-dark-400'}`} />
{/* Label */}
<span className="flex-1 text-sm text-dark-200 truncate">{info.label}</span>

View File

@@ -0,0 +1,342 @@
/**
* DevLoopPanel - Control panel for closed-loop development
*
* Features:
* - Start/stop development cycles
* - Real-time phase monitoring
* - Iteration history view
* - Test result visualization
*/
import { useState, useEffect, useCallback } from 'react';
import {
PlayCircle,
StopCircle,
RefreshCw,
CheckCircle,
XCircle,
AlertCircle,
Clock,
ListChecks,
Zap,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import useWebSocket from 'react-use-websocket';
interface LoopState {
phase: string;
iteration: number;
current_task: string | null;
last_update: string;
}
interface CycleResult {
objective: string;
status: string;
iterations: number;
duration_seconds: number;
}
interface TestResult {
scenario_id: string;
scenario_name: string;
passed: boolean;
duration_ms: number;
error?: string;
}
const PHASE_COLORS: Record<string, string> = {
idle: 'bg-gray-500',
planning: 'bg-blue-500',
implementing: 'bg-purple-500',
testing: 'bg-yellow-500',
analyzing: 'bg-orange-500',
fixing: 'bg-red-500',
verifying: 'bg-green-500',
};
const PHASE_ICONS: Record<string, React.ReactNode> = {
idle: <Clock className="w-4 h-4" />,
planning: <ListChecks className="w-4 h-4" />,
implementing: <Zap className="w-4 h-4" />,
testing: <RefreshCw className="w-4 h-4 animate-spin" />,
analyzing: <AlertCircle className="w-4 h-4" />,
fixing: <Zap className="w-4 h-4" />,
verifying: <CheckCircle className="w-4 h-4" />,
};
export function DevLoopPanel() {
const [state, setState] = useState<LoopState>({
phase: 'idle',
iteration: 0,
current_task: null,
last_update: new Date().toISOString(),
});
const [objective, setObjective] = useState('');
const [history, setHistory] = useState<CycleResult[]>([]);
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [expanded, setExpanded] = useState(true);
const [isStarting, setIsStarting] = useState(false);
// WebSocket connection for real-time updates
const { lastJsonMessage, readyState } = useWebSocket(
'ws://localhost:8000/api/devloop/ws',
{
shouldReconnect: () => true,
reconnectInterval: 3000,
}
);
// Handle WebSocket messages
useEffect(() => {
if (!lastJsonMessage) return;
const msg = lastJsonMessage as any;
switch (msg.type) {
case 'connection_ack':
case 'state_update':
case 'state':
if (msg.state) {
setState(msg.state);
}
break;
case 'cycle_complete':
setHistory(prev => [msg.result, ...prev].slice(0, 10));
setIsStarting(false);
break;
case 'cycle_error':
console.error('DevLoop error:', msg.error);
setIsStarting(false);
break;
case 'test_progress':
if (msg.result) {
setTestResults(prev => [...prev, msg.result]);
}
break;
}
}, [lastJsonMessage]);
// Start a development cycle
const startCycle = useCallback(async () => {
if (!objective.trim()) return;
setIsStarting(true);
setTestResults([]);
try {
const response = await fetch('http://localhost:8000/api/devloop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
objective: objective.trim(),
max_iterations: 10,
}),
});
if (!response.ok) {
const error = await response.json();
console.error('Failed to start cycle:', error);
setIsStarting(false);
}
} catch (error) {
console.error('Failed to start cycle:', error);
setIsStarting(false);
}
}, [objective]);
// Stop the current cycle
const stopCycle = useCallback(async () => {
try {
await fetch('http://localhost:8000/api/devloop/stop', {
method: 'POST',
});
} catch (error) {
console.error('Failed to stop cycle:', error);
}
}, []);
// Quick start: Create support_arm study
const quickStartSupportArm = useCallback(() => {
setObjective('Create support_arm optimization study with 5 design variables (center_space, arm_thk, arm_angle, end_thk, base_thk), objectives (minimize displacement, minimize mass), and stress constraint (< 30% yield)');
// Auto-start after a brief delay
setTimeout(() => {
startCycle();
}, 500);
}, [startCycle]);
const isActive = state.phase !== 'idle';
const wsConnected = readyState === WebSocket.OPEN;
return (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 bg-gray-800 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
<RefreshCw className="w-5 h-5 text-blue-400" />
<h3 className="font-semibold text-white">DevLoop Control</h3>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
wsConnected ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span className={`px-2 py-1 text-xs rounded ${PHASE_COLORS[state.phase]} text-white`}>
{state.phase.toUpperCase()}
</span>
</div>
</div>
{expanded && (
<div className="p-4 space-y-4">
{/* Objective Input */}
<div>
<label className="block text-sm text-gray-400 mb-1">
Development Objective
</label>
<textarea
value={objective}
onChange={(e) => setObjective(e.target.value)}
placeholder="e.g., Create support_arm optimization study..."
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white text-sm resize-none h-20"
disabled={isActive}
/>
</div>
{/* Quick Actions */}
<div className="flex gap-2">
<button
onClick={quickStartSupportArm}
disabled={isActive}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white text-sm rounded flex items-center gap-1"
>
<Zap className="w-4 h-4" />
Quick: support_arm
</button>
</div>
{/* Control Buttons */}
<div className="flex gap-2">
{!isActive ? (
<button
onClick={startCycle}
disabled={!objective.trim() || isStarting}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded flex items-center justify-center gap-2"
>
<PlayCircle className="w-5 h-5" />
{isStarting ? 'Starting...' : 'Start Cycle'}
</button>
) : (
<button
onClick={stopCycle}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
>
<StopCircle className="w-5 h-5" />
Stop Cycle
</button>
)}
</div>
{/* Current Phase Progress */}
{isActive && (
<div className="bg-gray-800 rounded p-3 space-y-2">
<div className="flex items-center gap-2">
{PHASE_ICONS[state.phase]}
<span className="text-sm text-white font-medium">
{state.phase.charAt(0).toUpperCase() + state.phase.slice(1)}
</span>
<span className="text-xs text-gray-400">
Iteration {state.iteration + 1}
</span>
</div>
{state.current_task && (
<p className="text-xs text-gray-400 truncate">
{state.current_task}
</p>
)}
</div>
)}
{/* Test Results */}
{testResults.length > 0 && (
<div className="bg-gray-800 rounded p-3">
<h4 className="text-sm font-medium text-white mb-2">Test Results</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{testResults.map((test, i) => (
<div
key={`${test.scenario_id}-${i}`}
className="flex items-center gap-2 text-xs"
>
{test.passed ? (
<CheckCircle className="w-3 h-3 text-green-500" />
) : (
<XCircle className="w-3 h-3 text-red-500" />
)}
<span className="text-gray-300 truncate flex-1">
{test.scenario_name}
</span>
<span className="text-gray-500">
{test.duration_ms.toFixed(0)}ms
</span>
</div>
))}
</div>
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="bg-gray-800 rounded p-3">
<h4 className="text-sm font-medium text-white mb-2">Recent Cycles</h4>
<div className="space-y-2">
{history.slice(0, 3).map((cycle, i) => (
<div
key={i}
className="flex items-center justify-between text-xs"
>
<span className="text-gray-300 truncate flex-1">
{cycle.objective.substring(0, 40)}...
</span>
<span
className={`px-1.5 py-0.5 rounded ${
cycle.status === 'completed'
? 'bg-green-900 text-green-300'
: 'bg-yellow-900 text-yellow-300'
}`}
>
{cycle.status}
</span>
</div>
))}
</div>
</div>
)}
{/* Phase Legend */}
<div className="grid grid-cols-4 gap-2 text-xs">
{Object.entries(PHASE_COLORS).map(([phase, color]) => (
<div key={phase} className="flex items-center gap-1">
<div className={`w-2 h-2 rounded ${color}`} />
<span className="text-gray-400 capitalize">{phase}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default DevLoopPanel;

View File

@@ -0,0 +1,292 @@
/**
* ContextFileUpload - Upload context files for study configuration
*
* Allows uploading markdown, text, PDF, and image files that help
* Claude understand optimization goals and generate better documentation.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle, Trash2, BookOpen } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface ContextFileUploadProps {
studyName: string;
onUploadComplete: () => void;
}
interface ContextFile {
name: string;
path: string;
size: number;
extension: string;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.json', '.csv'];
export const ContextFileUpload: React.FC<ContextFileUploadProps> = ({
studyName,
onUploadComplete,
}) => {
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load existing context files
const loadContextFiles = useCallback(async () => {
try {
const response = await intakeApi.listContextFiles(studyName);
setContextFiles(response.context_files);
} catch (err) {
console.error('Failed to load context files:', err);
}
}, [studyName]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!VALID_EXTENSIONS.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
// Max 10MB per file
if (file.size > 10 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 10MB)' };
}
return { valid: true };
};
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
// Skip duplicates
if (pendingFiles.some(f => f.file.name === file.name)) {
continue;
}
if (contextFiles.some(f => f.name === file.name)) {
continue;
}
const validation = validateFile(file);
if (validation.valid) {
validFiles.push({ file, status: 'pending' });
} else {
validFiles.push({ file, status: 'error', message: validation.reason });
}
}
setPendingFiles(prev => [...prev, ...validFiles]);
}, [pendingFiles, contextFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
addFiles(selectedFiles);
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
if (filesToUpload.length === 0) return;
setIsUploading(true);
setError(null);
try {
const response = await intakeApi.uploadContextFiles(
studyName,
filesToUpload.map(f => f.file)
);
// Update pending file statuses
const uploadResults = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setPendingFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = uploadResults.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
// Refresh and clear after a moment
setTimeout(() => {
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
loadContextFiles();
onUploadComplete();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setIsUploading(false);
}
};
const handleDeleteFile = async (filename: string) => {
try {
await intakeApi.deleteContextFile(studyName, filename);
loadContextFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed');
}
};
const pendingCount = pendingFiles.filter(f => f.status === 'pending').length;
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
Context Files
</h5>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
transition-colors"
>
<Upload className="w-3 h-3" />
Add Context
</button>
</div>
<p className="text-xs text-dark-500">
Add .md, .txt, or .pdf files describing your optimization goals. Claude will use these to generate documentation.
</p>
{/* Error Display */}
{error && (
<div className="p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-3 h-3 flex-shrink-0" />
{error}
<button onClick={() => setError(null)} className="ml-auto hover:text-white">
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Existing Context Files */}
{contextFiles.length > 0 && (
<div className="space-y-1">
{contextFiles.map((file) => (
<div
key={file.name}
className="flex items-center justify-between p-2 rounded-lg bg-purple-500/5 border border-purple-500/20"
>
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-purple-400" />
<span className="text-sm text-white">{file.name}</span>
<span className="text-xs text-dark-500">{formatSize(file.size)}</span>
</div>
<button
onClick={() => handleDeleteFile(file.name)}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-red-400"
title="Delete file"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Pending Files */}
{pendingFiles.length > 0 && (
<div className="space-y-1">
{pendingFiles.map((f, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 rounded-lg
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' :
'bg-dark-700'}`}
>
<div className="flex items-center gap-2">
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-purple-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
f.status === 'success' ? 'text-green-400' :
'text-white'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
</div>
{f.status === 'pending' && (
<button
onClick={() => removeFile(i)}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Upload Button */}
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
bg-purple-500 text-white text-sm font-medium
hover:bg-purple-400 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</>
)}
</button>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default ContextFileUpload;

View File

@@ -0,0 +1,227 @@
/**
* CreateStudyCard - Card for initiating new study creation
*
* Displays a prominent card on the Home page that allows users to
* create a new study through the intake workflow.
*/
import React, { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { TopicInfo } from '../../types/intake';
interface CreateStudyCardProps {
topics: TopicInfo[];
onStudyCreated: (studyName: string) => void;
}
export const CreateStudyCard: React.FC<CreateStudyCardProps> = ({
topics,
onStudyCreated,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [studyName, setStudyName] = useState('');
const [description, setDescription] = useState('');
const [selectedTopic, setSelectedTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!studyName.trim()) {
setError('Study name is required');
return;
}
// Validate study name format
const nameRegex = /^[a-z0-9_]+$/;
if (!nameRegex.test(studyName)) {
setError('Study name must be lowercase with underscores only (e.g., my_study_name)');
return;
}
setIsCreating(true);
setError(null);
try {
const topic = newTopic.trim() || selectedTopic || undefined;
await intakeApi.createInbox({
study_name: studyName.trim(),
description: description.trim() || undefined,
topic,
});
// Reset form
setStudyName('');
setDescription('');
setSelectedTopic('');
setNewTopic('');
setIsExpanded(false);
onStudyCreated(studyName.trim());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create study');
} finally {
setIsCreating(false);
}
};
if (!isExpanded) {
return (
<button
onClick={() => setIsExpanded(true)}
className="w-full glass rounded-xl p-6 border border-dashed border-primary-400/30
hover:border-primary-400/60 hover:bg-primary-400/5 transition-all
flex items-center justify-center gap-3 group"
>
<div className="w-12 h-12 rounded-xl bg-primary-400/10 flex items-center justify-center
group-hover:bg-primary-400/20 transition-colors">
<Plus className="w-6 h-6 text-primary-400" />
</div>
<div className="text-left">
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
<p className="text-sm text-dark-400">Set up a new optimization study</p>
</div>
</button>
);
}
return (
<div className="glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Plus className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
</div>
<button
onClick={() => setIsExpanded(false)}
className="text-dark-400 hover:text-white transition-colors text-sm"
>
Cancel
</button>
</div>
{/* Form */}
<div className="p-6 space-y-4">
{/* Study Name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Study Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={studyName}
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
placeholder="my_optimization_study"
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
<p className="mt-1 text-xs text-dark-500">
Lowercase letters, numbers, and underscores only
</p>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the optimization goal..."
rows={2}
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50 resize-none"
/>
</div>
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder
</label>
<div className="flex gap-2">
<select
value={selectedTopic}
onChange={(e) => {
setSelectedTopic(e.target.value);
setNewTopic('');
}}
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white focus:border-primary-400 focus:outline-none
focus:ring-1 focus:ring-primary-400/50"
>
<option value="">Select existing topic...</option>
{topics.map((topic) => (
<option key={topic.name} value={topic.name}>
{topic.name} ({topic.study_count} studies)
</option>
))}
</select>
<span className="text-dark-500 self-center">or</span>
<input
type="text"
value={newTopic}
onChange={(e) => {
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
setSelectedTopic('');
}}
placeholder="New_Topic"
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setIsExpanded(false)}
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
hover:border-dark-500 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={isCreating || !studyName.trim()}
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
color: '#000',
}}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4" />
Create Study
</>
)}
</button>
</div>
</div>
</div>
);
};
export default CreateStudyCard;

View File

@@ -0,0 +1,270 @@
/**
* ExpressionList - Display discovered expressions with selection capability
*
* Shows expressions from NX introspection, allowing users to:
* - View all discovered expressions
* - See which are design variable candidates (auto-detected)
* - Select/deselect expressions to use as design variables
* - View expression values and units
*/
import React, { useState } from 'react';
import {
Check,
Search,
AlertTriangle,
Sparkles,
Info,
Variable,
} from 'lucide-react';
import { ExpressionInfo } from '../../types/intake';
interface ExpressionListProps {
/** Expression data from introspection */
expressions: ExpressionInfo[];
/** Mass from introspection (kg) */
massKg?: number | null;
/** Currently selected expressions (to become DVs) */
selectedExpressions: string[];
/** Callback when selection changes */
onSelectionChange: (selected: string[]) => void;
/** Whether in read-only mode */
readOnly?: boolean;
/** Compact display mode */
compact?: boolean;
}
export const ExpressionList: React.FC<ExpressionListProps> = ({
expressions,
massKg,
selectedExpressions,
onSelectionChange,
readOnly = false,
compact = false,
}) => {
const [filter, setFilter] = useState('');
const [showCandidatesOnly, setShowCandidatesOnly] = useState(true);
// Filter expressions based on search and candidate toggle
const filteredExpressions = expressions.filter((expr) => {
const matchesSearch = filter === '' ||
expr.name.toLowerCase().includes(filter.toLowerCase());
const matchesCandidate = !showCandidatesOnly || expr.is_candidate;
return matchesSearch && matchesCandidate;
});
// Sort: candidates first, then by confidence, then alphabetically
const sortedExpressions = [...filteredExpressions].sort((a, b) => {
if (a.is_candidate !== b.is_candidate) {
return a.is_candidate ? -1 : 1;
}
if (a.confidence !== b.confidence) {
return b.confidence - a.confidence;
}
return a.name.localeCompare(b.name);
});
const toggleExpression = (name: string) => {
if (readOnly) return;
if (selectedExpressions.includes(name)) {
onSelectionChange(selectedExpressions.filter(n => n !== name));
} else {
onSelectionChange([...selectedExpressions, name]);
}
};
const selectAllCandidates = () => {
const candidateNames = expressions
.filter(e => e.is_candidate)
.map(e => e.name);
onSelectionChange(candidateNames);
};
const clearSelection = () => {
onSelectionChange([]);
};
const candidateCount = expressions.filter(e => e.is_candidate).length;
if (expressions.length === 0) {
return (
<div className="p-4 rounded-lg bg-dark-700/50 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400">
<AlertTriangle className="w-4 h-4" />
<span>No expressions found. Run introspection to discover model parameters.</span>
</div>
</div>
);
}
return (
<div className="space-y-3">
{/* Header with stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<Variable className="w-4 h-4" />
Discovered Expressions
</h5>
<span className="text-xs text-dark-500">
{expressions.length} total, {candidateCount} candidates
</span>
{massKg && (
<span className="text-xs text-primary-400">
Mass: {massKg.toFixed(3)} kg
</span>
)}
</div>
{!readOnly && selectedExpressions.length > 0 && (
<span className="text-xs text-green-400">
{selectedExpressions.length} selected
</span>
)}
</div>
{/* Controls */}
{!compact && (
<div className="flex items-center gap-3">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
<input
type="text"
placeholder="Search expressions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-dark-700 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-500/50 focus:outline-none"
/>
</div>
{/* Show candidates only toggle */}
<label className="flex items-center gap-2 text-xs text-dark-400 cursor-pointer">
<input
type="checkbox"
checked={showCandidatesOnly}
onChange={(e) => setShowCandidatesOnly(e.target.checked)}
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-primary-500
focus:ring-primary-500/30"
/>
Candidates only
</label>
{/* Quick actions */}
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={selectAllCandidates}
className="px-2 py-1 text-xs rounded bg-primary-500/10 text-primary-400
hover:bg-primary-500/20 transition-colors"
>
Select all candidates
</button>
<button
onClick={clearSelection}
className="px-2 py-1 text-xs rounded bg-dark-600 text-dark-400
hover:bg-dark-500 transition-colors"
>
Clear
</button>
</div>
)}
</div>
)}
{/* Expression list */}
<div className={`rounded-lg border border-dark-600 overflow-hidden ${
compact ? 'max-h-48' : 'max-h-72'
} overflow-y-auto`}>
<table className="w-full text-sm">
<thead className="bg-dark-700 sticky top-0">
<tr>
{!readOnly && (
<th className="w-8 px-2 py-2"></th>
)}
<th className="px-3 py-2 text-left text-dark-400 font-medium">Name</th>
<th className="px-3 py-2 text-right text-dark-400 font-medium w-24">Value</th>
<th className="px-3 py-2 text-left text-dark-400 font-medium w-16">Units</th>
<th className="px-3 py-2 text-center text-dark-400 font-medium w-20">Candidate</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{sortedExpressions.map((expr) => {
const isSelected = selectedExpressions.includes(expr.name);
return (
<tr
key={expr.name}
onClick={() => toggleExpression(expr.name)}
className={`
${readOnly ? '' : 'cursor-pointer hover:bg-dark-700/50'}
${isSelected ? 'bg-primary-500/10' : ''}
transition-colors
`}
>
{!readOnly && (
<td className="px-2 py-2">
<div className={`w-5 h-5 rounded border flex items-center justify-center
${isSelected
? 'bg-primary-500 border-primary-500'
: 'border-dark-500 bg-dark-700'
}`}
>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
</td>
)}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<code className={`text-xs ${isSelected ? 'text-primary-300' : 'text-white'}`}>
{expr.name}
</code>
{expr.formula && (
<span className="text-xs text-dark-500" title={expr.formula}>
<Info className="w-3 h-3" />
</span>
)}
</div>
</td>
<td className="px-3 py-2 text-right font-mono text-xs text-dark-300">
{expr.value !== null ? expr.value.toFixed(3) : '-'}
</td>
<td className="px-3 py-2 text-xs text-dark-400">
{expr.units || '-'}
</td>
<td className="px-3 py-2 text-center">
{expr.is_candidate ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
bg-green-500/10 text-green-400">
<Sparkles className="w-3 h-3" />
{Math.round(expr.confidence * 100)}%
</span>
) : (
<span className="text-xs text-dark-500">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{sortedExpressions.length === 0 && (
<div className="px-4 py-8 text-center text-dark-500">
No expressions match your filter
</div>
)}
</div>
{/* Help text */}
{!readOnly && !compact && (
<p className="text-xs text-dark-500">
Select expressions to use as design variables. Candidates (marked with %) are
automatically identified based on naming patterns and units.
</p>
)}
</div>
);
};
export default ExpressionList;

View File

@@ -0,0 +1,348 @@
/**
* FileDropzone - Drag and drop file upload component
*
* Supports drag-and-drop or click-to-browse for model files.
* Accepts .prt, .sim, .fem, .afem files.
*/
import React, { useState, useCallback, useRef } from 'react';
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface FileDropzoneProps {
studyName: string;
onUploadComplete: () => void;
compact?: boolean;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const VALID_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
export const FileDropzone: React.FC<FileDropzoneProps> = ({
studyName,
onUploadComplete,
compact = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!VALID_EXTENSIONS.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
// Max 500MB per file
if (file.size > 500 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 500MB)' };
}
return { valid: true };
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
// Skip duplicates
if (files.some(f => f.file.name === file.name)) {
continue;
}
const validation = validateFile(file);
if (validation.valid) {
validFiles.push({ file, status: 'pending' });
} else {
validFiles.push({ file, status: 'error', message: validation.reason });
}
}
setFiles(prev => [...prev, ...validFiles]);
}, [files]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
}, [addFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
addFiles(selectedFiles);
// Reset input so the same file can be selected again
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
const pendingFiles = files.filter(f => f.status === 'pending');
if (pendingFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
// Upload files
const response = await intakeApi.uploadFiles(
studyName,
pendingFiles.map(f => f.file)
);
// Update file statuses based on response
const uploadResults = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = uploadResults.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
// Clear successful uploads after a moment and refresh
setTimeout(() => {
setFiles(prev => prev.filter(f => f.status !== 'success'));
onUploadComplete();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
setFiles(prev => prev.map(f =>
f.status === 'pending'
? { ...f, status: 'error', message: 'Upload failed' }
: f
));
} finally {
setIsUploading(false);
}
};
const pendingCount = files.filter(f => f.status === 'pending').length;
if (compact) {
// Compact inline version
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white
transition-colors"
>
<Upload className="w-4 h-4" />
Add Files
</button>
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
disabled:opacity-50 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</button>
)}
</div>
{/* File list */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((f, i) => (
<span
key={i}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs
${f.status === 'error' ? 'bg-red-500/10 text-red-400' :
f.status === 'success' ? 'bg-green-500/10 text-green-400' :
'bg-dark-700 text-dark-300'}`}
>
{f.status === 'uploading' && <Loader2 className="w-3 h-3 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-3 h-3" />}
{f.status === 'error' && <AlertCircle className="w-3 h-3" />}
{f.file.name}
{f.status === 'pending' && (
<button onClick={() => removeFile(i)} className="hover:text-white">
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}
// Full dropzone version
return (
<div className="space-y-4">
{/* Dropzone */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl p-6 cursor-pointer
transition-all duration-200
${isDragging
? 'border-primary-400 bg-primary-400/5'
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
}
`}
>
<div className="flex flex-col items-center text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
<Upload className="w-6 h-6" />
</div>
<p className="text-white font-medium mb-1">
{isDragging ? 'Drop files here' : 'Drop model files here'}
</p>
<p className="text-sm text-dark-400">
or <span className="text-primary-400">click to browse</span>
</p>
<p className="text-xs text-dark-500 mt-2">
Accepts: {VALID_EXTENSIONS.join(', ')}
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h5 className="text-sm font-medium text-dark-300">Files to Upload</h5>
<div className="space-y-1">
{files.map((f, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 rounded-lg
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' :
'bg-dark-700'}`}
>
<div className="flex items-center gap-2">
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-primary-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
f.status === 'success' ? 'text-green-400' :
'text-white'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
</div>
{f.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
removeFile(i);
}}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{/* Upload Button */}
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg
bg-primary-500 text-white font-medium
hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</>
)}
</button>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default FileDropzone;

View File

@@ -0,0 +1,272 @@
/**
* FinalizeModal - Modal for finalizing an inbox study
*
* Allows user to:
* - Select/create topic folder
* - Choose whether to run baseline FEA
* - See progress during finalization
*/
import React, { useState, useEffect } from 'react';
import {
X,
Folder,
CheckCircle,
Loader2,
AlertCircle,
} from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { TopicInfo, InboxStudyDetail } from '../../types/intake';
interface FinalizeModalProps {
studyName: string;
topics: TopicInfo[];
onClose: () => void;
onFinalized: (finalPath: string) => void;
}
export const FinalizeModal: React.FC<FinalizeModalProps> = ({
studyName,
topics,
onClose,
onFinalized,
}) => {
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
const [selectedTopic, setSelectedTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [runBaseline, setRunBaseline] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isFinalizing, setIsFinalizing] = useState(false);
const [progress, setProgress] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Load study detail
useEffect(() => {
const loadStudy = async () => {
try {
const detail = await intakeApi.getInboxStudy(studyName);
setStudyDetail(detail);
// Pre-select topic if set in spec
if (detail.spec.meta.topic) {
setSelectedTopic(detail.spec.meta.topic);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load study');
} finally {
setIsLoading(false);
}
};
loadStudy();
}, [studyName]);
const handleFinalize = async () => {
const topic = newTopic.trim() || selectedTopic;
if (!topic) {
setError('Please select or create a topic folder');
return;
}
setIsFinalizing(true);
setError(null);
setProgress('Starting finalization...');
try {
setProgress('Validating study configuration...');
await new Promise((r) => setTimeout(r, 500)); // Visual feedback
if (runBaseline) {
setProgress('Running baseline FEA solve...');
}
const result = await intakeApi.finalize(studyName, {
topic,
run_baseline: runBaseline,
});
setProgress('Finalization complete!');
await new Promise((r) => setTimeout(r, 500));
onFinalized(result.final_path);
} catch (err) {
setError(err instanceof Error ? err.message : 'Finalization failed');
setIsFinalizing(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-900/80 backdrop-blur-sm">
<div className="w-full max-w-lg glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Folder className="w-5 h-5 text-primary-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Finalize Study</h3>
<p className="text-sm text-dark-400">{studyName}</p>
</div>
</div>
{!isFinalizing && (
<button
onClick={onClose}
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-400" />
</div>
) : isFinalizing ? (
/* Progress View */
<div className="text-center py-8 space-y-4">
<Loader2 className="w-12 h-12 animate-spin text-primary-400 mx-auto" />
<p className="text-white font-medium">{progress}</p>
<p className="text-sm text-dark-400">
Please wait while your study is being finalized...
</p>
</div>
) : (
<>
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Study Summary */}
{studyDetail && (
<div className="p-4 rounded-lg bg-dark-800 space-y-2">
<h4 className="text-sm font-medium text-dark-300">Study Summary</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-dark-500">Status:</span>
<span className="ml-2 text-white capitalize">
{studyDetail.spec.meta.status}
</span>
</div>
<div>
<span className="text-dark-500">Model Files:</span>
<span className="ml-2 text-white">
{studyDetail.files.sim.length + studyDetail.files.prt.length + studyDetail.files.fem.length}
</span>
</div>
<div>
<span className="text-dark-500">Design Variables:</span>
<span className="ml-2 text-white">
{studyDetail.spec.design_variables?.length || 0}
</span>
</div>
<div>
<span className="text-dark-500">Objectives:</span>
<span className="ml-2 text-white">
{studyDetail.spec.objectives?.length || 0}
</span>
</div>
</div>
</div>
)}
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder <span className="text-red-400">*</span>
</label>
<div className="flex gap-2">
<select
value={selectedTopic}
onChange={(e) => {
setSelectedTopic(e.target.value);
setNewTopic('');
}}
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white focus:border-primary-400 focus:outline-none
focus:ring-1 focus:ring-primary-400/50"
>
<option value="">Select existing topic...</option>
{topics.map((topic) => (
<option key={topic.name} value={topic.name}>
{topic.name} ({topic.study_count} studies)
</option>
))}
</select>
<span className="text-dark-500 self-center">or</span>
<input
type="text"
value={newTopic}
onChange={(e) => {
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
setSelectedTopic('');
}}
placeholder="New_Topic"
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
</div>
<p className="mt-1 text-xs text-dark-500">
Study will be created at: studies/{newTopic || selectedTopic || '<topic>'}/{studyName}/
</p>
</div>
{/* Baseline Option */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={runBaseline}
onChange={(e) => setRunBaseline(e.target.checked)}
className="w-4 h-4 rounded border-dark-600 bg-dark-800 text-primary-400
focus:ring-primary-400/50"
/>
<div>
<span className="text-white font-medium">Run baseline FEA solve</span>
<p className="text-xs text-dark-500">
Validates the model and captures baseline performance metrics
</p>
</div>
</label>
</div>
</>
)}
</div>
{/* Footer */}
{!isLoading && !isFinalizing && (
<div className="px-6 py-4 border-t border-primary-400/10 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
hover:border-dark-500 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleFinalize}
disabled={!selectedTopic && !newTopic.trim()}
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
color: '#000',
}}
>
<CheckCircle className="w-4 h-4" />
Finalize Study
</button>
</div>
)}
</div>
</div>
);
};
export default FinalizeModal;

View File

@@ -0,0 +1,147 @@
/**
* InboxSection - Section displaying inbox studies on Home page
*
* Shows the "Create New Study" card and lists all inbox studies
* with their current status and available actions.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Inbox, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { InboxStudy, TopicInfo } from '../../types/intake';
import { CreateStudyCard } from './CreateStudyCard';
import { InboxStudyCard } from './InboxStudyCard';
import { FinalizeModal } from './FinalizeModal';
interface InboxSectionProps {
onStudyFinalized?: () => void;
}
export const InboxSection: React.FC<InboxSectionProps> = ({ onStudyFinalized }) => {
const [inboxStudies, setInboxStudies] = useState<InboxStudy[]>([]);
const [topics, setTopics] = useState<TopicInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isExpanded, setIsExpanded] = useState(true);
const [selectedStudyForFinalize, setSelectedStudyForFinalize] = useState<string | null>(null);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [inboxResponse, topicsResponse] = await Promise.all([
intakeApi.listInbox(),
intakeApi.listTopics(),
]);
setInboxStudies(inboxResponse.studies);
setTopics(topicsResponse.topics);
} catch (err) {
console.error('Failed to load inbox data:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleStudyCreated = (_studyName: string) => {
loadData();
};
const handleStudyFinalized = (_finalPath: string) => {
setSelectedStudyForFinalize(null);
loadData();
onStudyFinalized?.();
};
const pendingStudies = inboxStudies.filter(
(s) => !['ready', 'running', 'completed'].includes(s.status)
);
return (
<div className="space-y-4">
{/* Section Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-2 py-1 hover:bg-white/5 rounded-lg transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Inbox className="w-4 h-4 text-primary-400" />
</div>
<div className="text-left">
<h2 className="text-lg font-semibold text-white">Study Inbox</h2>
<p className="text-sm text-dark-400">
{pendingStudies.length} pending studies
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
loadData();
}}
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-primary-400"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-dark-400" />
) : (
<ChevronRight className="w-5 h-5 text-dark-400" />
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="space-y-4">
{/* Create Study Card */}
<CreateStudyCard topics={topics} onStudyCreated={handleStudyCreated} />
{/* Inbox Studies List */}
{inboxStudies.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 px-2">
Inbox Studies ({inboxStudies.length})
</h3>
{inboxStudies.map((study) => (
<InboxStudyCard
key={study.study_name}
study={study}
onRefresh={loadData}
onSelect={setSelectedStudyForFinalize}
/>
))}
</div>
)}
{/* Empty State */}
{!isLoading && inboxStudies.length === 0 && (
<div className="text-center py-8 text-dark-400">
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No studies in inbox</p>
<p className="text-sm text-dark-500">
Create a new study to get started
</p>
</div>
)}
</div>
)}
{/* Finalize Modal */}
{selectedStudyForFinalize && (
<FinalizeModal
studyName={selectedStudyForFinalize}
topics={topics}
onClose={() => setSelectedStudyForFinalize(null)}
onFinalized={handleStudyFinalized}
/>
)}
</div>
);
};
export default InboxSection;

View File

@@ -0,0 +1,455 @@
/**
* InboxStudyCard - Card displaying an inbox study with actions
*
* Shows study status, files, and provides actions for:
* - Running introspection
* - Generating README
* - Finalizing the study
*/
import React, { useState, useEffect } from 'react';
import {
FileText,
Folder,
Trash2,
Play,
CheckCircle,
Clock,
AlertCircle,
Loader2,
ChevronDown,
ChevronRight,
Sparkles,
ArrowRight,
Eye,
Save,
} from 'lucide-react';
import { InboxStudy, SpecStatus, ExpressionInfo, InboxStudyDetail } from '../../types/intake';
import { intakeApi } from '../../api/intake';
import { FileDropzone } from './FileDropzone';
import { ContextFileUpload } from './ContextFileUpload';
import { ExpressionList } from './ExpressionList';
interface InboxStudyCardProps {
study: InboxStudy;
onRefresh: () => void;
onSelect: (studyName: string) => void;
}
const statusConfig: Record<SpecStatus, { icon: React.ReactNode; color: string; label: string }> = {
draft: {
icon: <Clock className="w-4 h-4" />,
color: 'text-dark-400 bg-dark-600',
label: 'Draft',
},
introspected: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-blue-400 bg-blue-500/10',
label: 'Introspected',
},
configured: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Configured',
},
validated: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Validated',
},
ready: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-primary-400 bg-primary-500/10',
label: 'Ready',
},
running: {
icon: <Play className="w-4 h-4" />,
color: 'text-yellow-400 bg-yellow-500/10',
label: 'Running',
},
completed: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Completed',
},
failed: {
icon: <AlertCircle className="w-4 h-4" />,
color: 'text-red-400 bg-red-500/10',
label: 'Failed',
},
};
export const InboxStudyCard: React.FC<InboxStudyCardProps> = ({
study,
onRefresh,
onSelect,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isIntrospecting, setIsIntrospecting] = useState(false);
const [isGeneratingReadme, setIsGeneratingReadme] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Introspection data (fetched when expanded)
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [selectedExpressions, setSelectedExpressions] = useState<string[]>([]);
const [showReadme, setShowReadme] = useState(false);
const [readmeContent, setReadmeContent] = useState<string | null>(null);
const [isSavingDVs, setIsSavingDVs] = useState(false);
const [dvSaveMessage, setDvSaveMessage] = useState<string | null>(null);
const status = statusConfig[study.status] || statusConfig.draft;
// Fetch study details when expanded for the first time
useEffect(() => {
if (isExpanded && !studyDetail && !isLoadingDetail) {
loadStudyDetail();
}
}, [isExpanded]);
const loadStudyDetail = async () => {
setIsLoadingDetail(true);
try {
const detail = await intakeApi.getInboxStudy(study.study_name);
setStudyDetail(detail);
// Auto-select candidate expressions
const introspection = detail.spec?.model?.introspection;
if (introspection?.expressions) {
const candidates = introspection.expressions
.filter((e: ExpressionInfo) => e.is_candidate)
.map((e: ExpressionInfo) => e.name);
setSelectedExpressions(candidates);
}
} catch (err) {
console.error('Failed to load study detail:', err);
} finally {
setIsLoadingDetail(false);
}
};
const handleIntrospect = async () => {
setIsIntrospecting(true);
setError(null);
try {
await intakeApi.introspect({ study_name: study.study_name });
// Reload study detail to get new introspection data
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Introspection failed');
} finally {
setIsIntrospecting(false);
}
};
const handleGenerateReadme = async () => {
setIsGeneratingReadme(true);
setError(null);
try {
const response = await intakeApi.generateReadme(study.study_name);
setReadmeContent(response.content);
setShowReadme(true);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'README generation failed');
} finally {
setIsGeneratingReadme(false);
}
};
const handleDelete = async () => {
if (!confirm(`Delete inbox study "${study.study_name}"? This cannot be undone.`)) {
return;
}
setIsDeleting(true);
try {
await intakeApi.deleteInboxStudy(study.study_name);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed');
setIsDeleting(false);
}
};
const handleSaveDesignVariables = async () => {
if (selectedExpressions.length === 0) {
setError('Please select at least one expression to use as a design variable');
return;
}
setIsSavingDVs(true);
setError(null);
setDvSaveMessage(null);
try {
const result = await intakeApi.createDesignVariables(study.study_name, selectedExpressions);
setDvSaveMessage(`Created ${result.total_created} design variable(s)`);
// Reload study detail to see updated spec
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save design variables');
} finally {
setIsSavingDVs(false);
}
};
const canIntrospect = study.status === 'draft' && study.model_files.length > 0;
const canGenerateReadme = study.status === 'introspected';
const canFinalize = ['introspected', 'configured'].includes(study.status);
const canSaveDVs = study.status === 'introspected' && selectedExpressions.length > 0;
return (
<div className="glass rounded-xl border border-primary-400/10 overflow-hidden">
{/* Header - Always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center">
<Folder className="w-5 h-5 text-primary-400" />
</div>
<div className="text-left">
<h4 className="text-white font-medium">{study.study_name}</h4>
{study.description && (
<p className="text-sm text-dark-400 truncate max-w-[300px]">
{study.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Status Badge */}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.color}`}>
{status.icon}
{status.label}
</span>
{/* File Count */}
<span className="text-dark-500 text-sm">
{study.model_files.length} files
</span>
{/* Expand Icon */}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-dark-400" />
) : (
<ChevronRight className="w-4 h-4 text-dark-400" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-4 pb-4 space-y-4 border-t border-primary-400/10 pt-4">
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Success Message */}
{dvSaveMessage && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4 flex-shrink-0" />
{dvSaveMessage}
</div>
)}
{/* Files Section */}
{study.model_files.length > 0 && (
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Model Files</h5>
<div className="flex flex-wrap gap-2">
{study.model_files.map((file) => (
<span
key={file}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-dark-300 text-xs"
>
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Model File Upload Section */}
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Upload Model Files</h5>
<FileDropzone
studyName={study.study_name}
onUploadComplete={onRefresh}
compact={true}
/>
</div>
{/* Context File Upload Section */}
<ContextFileUpload
studyName={study.study_name}
onUploadComplete={onRefresh}
/>
{/* Introspection Results - Expressions */}
{isLoadingDetail && (
<div className="flex items-center gap-2 text-dark-400 text-sm py-4">
<Loader2 className="w-4 h-4 animate-spin" />
Loading introspection data...
</div>
)}
{studyDetail?.spec?.model?.introspection?.expressions &&
studyDetail.spec.model.introspection.expressions.length > 0 && (
<ExpressionList
expressions={studyDetail.spec.model.introspection.expressions}
massKg={studyDetail.spec.model.introspection.mass_kg}
selectedExpressions={selectedExpressions}
onSelectionChange={setSelectedExpressions}
readOnly={study.status === 'configured'}
compact={true}
/>
)}
{/* README Preview Section */}
{(readmeContent || study.status === 'configured') && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<FileText className="w-4 h-4" />
README.md
</h5>
<button
onClick={() => setShowReadme(!showReadme)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-dark-600
text-dark-300 hover:bg-dark-500 transition-colors"
>
<Eye className="w-3 h-3" />
{showReadme ? 'Hide' : 'Preview'}
</button>
</div>
{showReadme && readmeContent && (
<div className="max-h-64 overflow-y-auto rounded-lg border border-dark-600
bg-dark-800 p-4">
<pre className="text-xs text-dark-300 whitespace-pre-wrap font-mono">
{readmeContent}
</pre>
</div>
)}
</div>
)}
{/* No Files Warning */}
{study.model_files.length === 0 && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
No model files found. Upload .prt, .sim, or .fem files to continue.
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2">
{/* Introspect */}
{canIntrospect && (
<button
onClick={handleIntrospect}
disabled={isIntrospecting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-blue-500/10 text-blue-400 hover:bg-blue-500/20
disabled:opacity-50 transition-colors"
>
{isIntrospecting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
Introspect Model
</button>
)}
{/* Save Design Variables */}
{canSaveDVs && (
<button
onClick={handleSaveDesignVariables}
disabled={isSavingDVs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-green-500/10 text-green-400 hover:bg-green-500/20
disabled:opacity-50 transition-colors"
>
{isSavingDVs ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save as DVs ({selectedExpressions.length})
</button>
)}
{/* Generate README */}
{canGenerateReadme && (
<button
onClick={handleGenerateReadme}
disabled={isGeneratingReadme}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
disabled:opacity-50 transition-colors"
>
{isGeneratingReadme ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Generate README
</button>
)}
{/* Finalize */}
{canFinalize && (
<button
onClick={() => onSelect(study.study_name)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
transition-colors"
>
<ArrowRight className="w-4 h-4" />
Finalize Study
</button>
)}
{/* Delete */}
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-red-500/10 text-red-400 hover:bg-red-500/20
disabled:opacity-50 transition-colors ml-auto"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</button>
</div>
{/* Workflow Hint */}
{study.status === 'draft' && study.model_files.length > 0 && (
<p className="text-xs text-dark-500">
Next step: Run introspection to discover expressions and model properties.
</p>
)}
{study.status === 'introspected' && (
<p className="text-xs text-dark-500">
Next step: Generate README with Claude AI, then finalize to create the study.
</p>
)}
</div>
)}
</div>
);
};
export default InboxStudyCard;

View File

@@ -0,0 +1,13 @@
/**
* Intake Components Index
*
* Export all intake workflow components.
*/
export { CreateStudyCard } from './CreateStudyCard';
export { InboxStudyCard } from './InboxStudyCard';
export { FinalizeModal } from './FinalizeModal';
export { InboxSection } from './InboxSection';
export { FileDropzone } from './FileDropzone';
export { ContextFileUpload } from './ContextFileUpload';
export { ExpressionList } from './ExpressionList';

View File

@@ -1,260 +0,0 @@
/**
* PlotlyConvergencePlot - Interactive convergence plot using Plotly
*
* Features:
* - Line plot showing objective vs trial number
* - Best-so-far trace overlay
* - FEA vs NN trial differentiation
* - Hover tooltips with trial details
* - Range slider for zooming
* - Log scale toggle
* - Export to PNG/SVG
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
// Penalty threshold - objectives above this are considered failed/penalty trials
const PENALTY_THRESHOLD = 100000;
interface PlotlyConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
height?: number;
showRangeSlider?: boolean;
showLogScaleToggle?: boolean;
}
export function PlotlyConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize',
height = 400,
showRangeSlider = true,
showLogScaleToggle = true
}: PlotlyConvergencePlotProps) {
const [useLogScale, setUseLogScale] = useState(false);
// Process trials and calculate best-so-far
const { feaData, nnData, bestSoFar, allX, allY } = useMemo(() => {
if (!trials.length) return { feaData: { x: [], y: [], text: [] }, nnData: { x: [], y: [], text: [] }, bestSoFar: { x: [], y: [] }, allX: [], allY: [] };
// Sort by trial number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const fea: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const nn: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const best: { x: number[]; y: number[] } = { x: [], y: [] };
const xs: number[] = [];
const ys: number[] = [];
let bestValue = direction === 'minimize' ? Infinity : -Infinity;
sorted.forEach(t => {
const val = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName] ?? null;
if (val === null || !isFinite(val)) return;
// Filter out failed/penalty trials:
// 1. Objective above penalty threshold (e.g., 1000000 = solver failure)
// 2. constraint_satisfied explicitly false
// 3. user_attrs indicates pruned/failed
const isPenalty = val >= PENALTY_THRESHOLD;
const isFailed = t.constraint_satisfied === false;
const isPruned = t.user_attrs?.pruned === true || t.user_attrs?.fail_reason;
if (isPenalty || isFailed || isPruned) return;
const source = t.source || t.user_attrs?.source || 'FEA';
const hoverText = `Trial #${t.trial_number}<br>${objectiveName}: ${val.toFixed(4)}<br>Source: ${source}`;
xs.push(t.trial_number);
ys.push(val);
if (source === 'NN') {
nn.x.push(t.trial_number);
nn.y.push(val);
nn.text.push(hoverText);
} else {
fea.x.push(t.trial_number);
fea.y.push(val);
fea.text.push(hoverText);
}
// Update best-so-far
if (direction === 'minimize') {
if (val < bestValue) bestValue = val;
} else {
if (val > bestValue) bestValue = val;
}
best.x.push(t.trial_number);
best.y.push(bestValue);
});
return { feaData: fea, nnData: nn, bestSoFar: best, allX: xs, allY: ys };
}, [trials, objectiveIndex, objectiveName, direction]);
if (!trials.length || allX.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available
</div>
);
}
const traces: any[] = [];
// FEA trials scatter
if (feaData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `FEA (${feaData.x.length})`,
x: feaData.x,
y: feaData.y,
text: feaData.text,
hoverinfo: 'text',
marker: {
color: '#3B82F6',
size: 8,
opacity: 0.7,
line: { color: '#1E40AF', width: 1 }
}
});
}
// NN trials scatter
if (nnData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `NN (${nnData.x.length})`,
x: nnData.x,
y: nnData.y,
text: nnData.text,
hoverinfo: 'text',
marker: {
color: '#F97316',
size: 6,
symbol: 'cross',
opacity: 0.6
}
});
}
// Best-so-far line
if (bestSoFar.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'lines',
name: 'Best So Far',
x: bestSoFar.x,
y: bestSoFar.y,
line: {
color: '#10B981',
width: 3,
shape: 'hv' // Step line
},
hoverinfo: 'y'
});
}
const layout: any = {
height,
margin: { l: 60, r: 30, t: 30, b: showRangeSlider ? 80 : 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: 'Trial Number',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB',
rangeslider: showRangeSlider ? { visible: true } : undefined
},
yaxis: {
title: useLogScale ? `log₁₀(${objectiveName})` : objectiveName,
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB',
type: useLogScale ? 'log' : 'linear'
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
hovermode: 'closest'
};
// Best value annotation
const bestVal = direction === 'minimize'
? Math.min(...allY)
: Math.max(...allY);
const bestIdx = allY.indexOf(bestVal);
const bestTrial = allX[bestIdx];
return (
<div className="w-full">
{/* Summary stats and controls */}
<div className="flex items-center justify-between mb-3">
<div className="flex gap-6 text-sm">
<div className="text-gray-600">
Best: <span className="font-semibold text-green-600">{bestVal.toFixed(4)}</span>
<span className="text-gray-400 ml-1">(Trial #{bestTrial})</span>
</div>
<div className="text-gray-600">
Current: <span className="font-semibold">{allY[allY.length - 1].toFixed(4)}</span>
</div>
<div className="text-gray-600">
Trials: <span className="font-semibold">{allX.length}</span>
</div>
</div>
{/* Log scale toggle */}
{showLogScaleToggle && (
<button
onClick={() => setUseLogScale(!useLogScale)}
className={`px-3 py-1 text-xs rounded transition-colors ${
useLogScale
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
title="Toggle logarithmic scale - better for viewing early improvements"
>
{useLogScale ? 'Log Scale' : 'Linear Scale'}
</button>
)}
</div>
<Plot
data={traces}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'convergence_plot',
height: 600,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
</div>
);
}

View File

@@ -1,161 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface PlotlyCorrelationHeatmapProps {
trials: TrialData[];
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0 || n !== y.length) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
export function PlotlyCorrelationHeatmap({
trials,
objectiveName = 'Objective',
height = 500
}: PlotlyCorrelationHeatmapProps) {
const { matrix, labels, annotations } = useMemo(() => {
if (trials.length < 3) {
return { matrix: [], labels: [], annotations: [] };
}
// Get parameter names
const paramNames = Object.keys(trials[0].params);
const allLabels = [...paramNames, objectiveName];
// Extract data columns
const columns: Record<string, number[]> = {};
paramNames.forEach(name => {
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
});
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
// Calculate correlation matrix
const n = allLabels.length;
const correlationMatrix: number[][] = [];
const annotationData: any[] = [];
for (let i = 0; i < n; i++) {
const row: number[] = [];
for (let j = 0; j < n; j++) {
const col1 = columns[allLabels[i]];
const col2 = columns[allLabels[j]];
// Ensure same length
const minLen = Math.min(col1.length, col2.length);
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
row.push(corr);
// Add annotation
annotationData.push({
x: allLabels[j],
y: allLabels[i],
text: corr.toFixed(2),
showarrow: false,
font: {
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
size: 11
}
});
}
correlationMatrix.push(row);
}
return {
matrix: correlationMatrix,
labels: allLabels,
annotations: annotationData
};
}, [trials, objectiveName]);
if (trials.length < 3) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>Need at least 3 trials to compute correlations</p>
</div>
);
}
return (
<Plot
data={[
{
z: matrix,
x: labels,
y: labels,
type: 'heatmap',
colorscale: [
[0, '#ef4444'], // -1: strong negative (red)
[0.25, '#f87171'], // -0.5: moderate negative
[0.5, '#1a1b26'], // 0: no correlation (dark)
[0.75, '#60a5fa'], // 0.5: moderate positive
[1, '#3b82f6'] // 1: strong positive (blue)
],
zmin: -1,
zmax: 1,
showscale: true,
colorbar: {
title: { text: 'Correlation', font: { color: '#888' } },
tickfont: { color: '#888' },
len: 0.8
},
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
}
]}
layout={{
title: {
text: 'Parameter-Objective Correlation Matrix',
font: { color: '#fff', size: 14 }
},
height,
margin: { l: 120, r: 60, t: 60, b: 120 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
tickangle: 45,
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
annotations: annotations
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -1,120 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
constraint_satisfied?: boolean;
}
interface PlotlyFeasibilityChartProps {
trials: TrialData[];
height?: number;
}
export function PlotlyFeasibilityChart({
trials,
height = 350
}: PlotlyFeasibilityChartProps) {
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
if (trials.length === 0) {
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
}
// Sort trials by number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const numbers: number[] = [];
const cumulative: number[] = [];
const windowed: number[] = [];
let feasibleCount = 0;
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
sorted.forEach((trial, idx) => {
numbers.push(trial.trial_number);
// Cumulative feasibility
if (trial.constraint_satisfied !== false) {
feasibleCount++;
}
cumulative.push((feasibleCount / (idx + 1)) * 100);
// Windowed (rolling) feasibility
const windowStart = Math.max(0, idx - windowSize + 1);
const windowTrials = sorted.slice(windowStart, idx + 1);
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
windowed.push((windowFeasible / windowTrials.length) * 100);
});
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
}, [trials]);
if (trials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No trials to display</p>
</div>
);
}
return (
<Plot
data={[
{
x: trialNumbers,
y: cumulativeFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Cumulative Feasibility',
line: { color: '#22c55e', width: 2 },
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
},
{
x: trialNumbers,
y: windowedFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Rolling (20-trial)',
line: { color: '#60a5fa', width: 2, dash: 'dot' },
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
}
]}
layout={{
height,
margin: { l: 60, r: 30, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)',
zeroline: false
},
yaxis: {
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)',
zeroline: false,
range: [0, 105]
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
x: 0.02,
y: 0.98,
xanchor: 'left',
yanchor: 'top'
},
showlegend: true,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -1,221 +0,0 @@
/**
* PlotlyParallelCoordinates - Interactive parallel coordinates plot using Plotly
*
* Features:
* - Native zoom, pan, and selection
* - Hover tooltips with trial details
* - Brush filtering on each axis
* - FEA vs NN color differentiation
* - Export to PNG/SVG
*/
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
interface PlotlyParallelCoordinatesProps {
trials: Trial[];
objectives: Objective[];
designVariables: DesignVariable[];
paretoFront?: Trial[];
height?: number;
}
export function PlotlyParallelCoordinates({
trials,
objectives,
designVariables,
paretoFront = [],
height = 500
}: PlotlyParallelCoordinatesProps) {
// Create set of Pareto front trial numbers
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Build dimensions array for parallel coordinates
const { dimensions, colorValues, colorScale } = useMemo(() => {
if (!trials.length) return { dimensions: [], colorValues: [], colorScale: [] };
const dims: any[] = [];
const colors: number[] = [];
// Get all design variable names
const dvNames = designVariables.map(dv => dv.name);
const objNames = objectives.map(obj => obj.name);
// Add design variable dimensions
dvNames.forEach((name, idx) => {
const dv = designVariables[idx];
const values = trials.map(t => t.params[name] ?? 0);
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: name,
values: values,
range: [
dv?.min ?? Math.min(...validValues),
dv?.max ?? Math.max(...validValues)
],
constraintrange: undefined
});
});
// Add objective dimensions
objNames.forEach((name, idx) => {
const obj = objectives[idx];
const values = trials.map(t => {
// Try to get from values array first, then user_attrs
if (t.values && t.values[idx] !== undefined) {
return t.values[idx];
}
return t.user_attrs?.[name] ?? 0;
});
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: `${name}${obj.unit ? ` (${obj.unit})` : ''}`,
values: values,
range: [Math.min(...validValues) * 0.95, Math.max(...validValues) * 1.05]
});
});
// Build color array: 0 = V10_FEA, 1 = FEA, 2 = NN, 3 = Pareto
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
const isPareto = paretoSet.has(t.trial_number);
if (isPareto) {
colors.push(3); // Pareto - special color
} else if (source === 'NN') {
colors.push(2); // NN trials
} else if (source === 'V10_FEA') {
colors.push(0); // V10 FEA
} else {
colors.push(1); // V11 FEA
}
});
// Color scale: V10_FEA (light blue), FEA (blue), NN (orange), Pareto (green)
const scale: [number, string][] = [
[0, '#93C5FD'], // V10_FEA - light blue
[0.33, '#2563EB'], // FEA - blue
[0.66, '#F97316'], // NN - orange
[1, '#10B981'] // Pareto - green
];
return { dimensions: dims, colorValues: colors, colorScale: scale };
}, [trials, objectives, designVariables, paretoSet]);
if (!trials.length || dimensions.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available for parallel coordinates
</div>
);
}
// Count trial types for legend
const feaCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'FEA' || source === 'V10_FEA';
}).length;
const nnCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'NN';
}).length;
return (
<div className="w-full">
{/* Legend */}
<div className="flex gap-4 justify-center mb-2 text-sm">
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#2563EB' }} />
<span className="text-gray-600">FEA ({feaCount})</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#F97316' }} />
<span className="text-gray-600">NN ({nnCount})</span>
</div>
{paretoFront.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#10B981' }} />
<span className="text-gray-600">Pareto ({paretoFront.length})</span>
</div>
)}
</div>
<Plot
data={[
{
type: 'parcoords',
line: {
color: colorValues,
colorscale: colorScale as any,
showscale: false
},
dimensions: dimensions,
labelangle: -30,
labelfont: {
size: 11,
color: '#374151'
},
tickfont: {
size: 10,
color: '#6B7280'
}
} as any
]}
layout={{
height: height,
margin: { l: 80, r: 80, t: 30, b: 30 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: {
family: 'Inter, system-ui, sans-serif'
}
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parallel_coordinates',
height: 800,
width: 1400,
scale: 2
}
}}
style={{ width: '100%' }}
/>
<p className="text-xs text-gray-500 text-center mt-2">
Drag along axes to filter. Double-click to reset.
</p>
</div>
);
}

View File

@@ -1,209 +0,0 @@
/**
* PlotlyParameterImportance - Interactive parameter importance chart using Plotly
*
* Features:
* - Horizontal bar chart showing correlation/importance
* - Color coding by positive/negative correlation
* - Hover tooltips with details
* - Sortable by importance
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
}
interface DesignVariable {
name: string;
unit?: string;
}
interface PlotlyParameterImportanceProps {
trials: Trial[];
designVariables: DesignVariable[];
objectiveIndex?: number;
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0) return 0;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
if (denominator === 0) return 0;
return numerator / denominator;
}
export function PlotlyParameterImportance({
trials,
designVariables,
objectiveIndex = 0,
objectiveName = 'Objective',
height = 400
}: PlotlyParameterImportanceProps) {
const [sortBy, setSortBy] = useState<'importance' | 'name'>('importance');
// Calculate correlations for each parameter
const correlations = useMemo(() => {
if (!trials.length || !designVariables.length) return [];
// Get objective values
const objValues = trials.map(t => {
if (t.values && t.values[objectiveIndex] !== undefined) {
return t.values[objectiveIndex];
}
return t.user_attrs?.[objectiveName] ?? null;
}).filter((v): v is number => v !== null && isFinite(v));
if (objValues.length < 3) return []; // Need at least 3 points for correlation
const results: { name: string; correlation: number; absCorrelation: number }[] = [];
designVariables.forEach(dv => {
const paramValues = trials
.map((t) => {
const objVal = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName];
if (objVal === null || objVal === undefined || !isFinite(objVal)) return null;
return { param: t.params[dv.name], obj: objVal };
})
.filter((v): v is { param: number; obj: number } => v !== null && v.param !== undefined);
if (paramValues.length < 3) return;
const x = paramValues.map(v => v.param);
const y = paramValues.map(v => v.obj);
const corr = pearsonCorrelation(x, y);
results.push({
name: dv.name,
correlation: corr,
absCorrelation: Math.abs(corr)
});
});
// Sort by absolute correlation or name
if (sortBy === 'importance') {
results.sort((a, b) => b.absCorrelation - a.absCorrelation);
} else {
results.sort((a, b) => a.name.localeCompare(b.name));
}
return results;
}, [trials, designVariables, objectiveIndex, objectiveName, sortBy]);
if (!correlations.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
Not enough data to calculate parameter importance
</div>
);
}
// Build bar chart data
const names = correlations.map(c => c.name);
const values = correlations.map(c => c.correlation);
const colors = values.map(v => v > 0 ? '#EF4444' : '#22C55E'); // Red for positive (worse), Green for negative (better) when minimizing
const hoverTexts = correlations.map(c =>
`${c.name}<br>Correlation: ${c.correlation.toFixed(4)}<br>|r|: ${c.absCorrelation.toFixed(4)}<br>${c.correlation > 0 ? 'Higher → Higher objective' : 'Higher → Lower objective'}`
);
return (
<div className="w-full">
{/* Controls */}
<div className="flex justify-between items-center mb-3">
<div className="text-sm text-gray-600">
Correlation with <span className="font-semibold">{objectiveName}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setSortBy('importance')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'importance' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Importance
</button>
<button
onClick={() => setSortBy('name')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Name
</button>
</div>
</div>
<Plot
data={[
{
type: 'bar',
orientation: 'h',
y: names,
x: values,
text: hoverTexts,
hoverinfo: 'text',
marker: {
color: colors,
line: { color: '#fff', width: 1 }
}
}
]}
layout={{
height: Math.max(height, correlations.length * 30 + 80),
margin: { l: 150, r: 30, t: 10, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: { text: 'Correlation Coefficient' },
range: [-1, 1],
gridcolor: '#E5E7EB',
zerolinecolor: '#9CA3AF',
zerolinewidth: 2
},
yaxis: {
automargin: true
},
font: { family: 'Inter, system-ui, sans-serif', size: 11 },
bargap: 0.3
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parameter_importance',
height: 600,
width: 800,
scale: 2
}
}}
style={{ width: '100%' }}
/>
{/* Legend */}
<div className="flex gap-6 justify-center mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#EF4444' }} />
<span className="text-gray-600">Positive correlation (higher param higher objective)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#22C55E' }} />
<span className="text-gray-600">Negative correlation (higher param lower objective)</span>
</div>
</div>
</div>
);
}

View File

@@ -1,448 +0,0 @@
/**
* PlotlyParetoPlot - Interactive Pareto front visualization using Plotly
*
* Features:
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Pareto front connection line
* - FEA vs NN differentiation
* - Constraint satisfaction highlighting
* - Dark mode styling
* - Zoom, pan, and export
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface PlotlyParetoPlotProps {
trials: Trial[];
paretoFront: Trial[];
objectives: Objective[];
height?: number;
showParetoLine?: boolean;
showInfeasible?: boolean;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500,
showParetoLine = true,
showInfeasible = true
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source, Pareto status, and constraint satisfaction
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
const infeasible: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
if (!isFeasible && showInfeasible) {
infeasible.push(t);
} else if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
} else {
fea.push(t);
}
});
// Calculate statistics
const stats = {
totalTrials: trials.length,
paretoCount: pareto.length,
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
infeasibleCount: infeasible.length,
hypervolume: 0 // Could calculate if needed
};
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
}, [trials, paretoSet, showInfeasible]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
if (trial.values && trial.values[idx] !== undefined) {
return trial.values[idx];
}
const objName = objectives[idx]?.name;
return trial.user_attrs?.[objName] ?? 0;
};
// Build hover text
const buildHoverText = (trial: Trial): string => {
const lines = [`Trial #${trial.trial_number}`];
objectives.forEach((obj, i) => {
const val = getObjValue(trial, i);
lines.push(`${obj.name}: ${val.toFixed(4)}${obj.unit ? ` ${obj.unit}` : ''}`);
});
const source = trial.source || trial.user_attrs?.source || 'FEA';
lines.push(`Source: ${source}`);
return lines.join('<br>');
};
// Create trace data
const createTrace = (
trialList: Trial[],
name: string,
color: string,
symbol: string,
size: number,
opacity: number
) => {
const [i, j, k] = selectedObjectives;
if (viewMode === '3d' && objectives.length >= 3) {
return {
type: 'scatter3d' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
z: trialList.map(t => getObjValue(t, k)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
} else {
return {
type: 'scatter' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
}
};
// Sort Pareto trials by first objective for line connection
const sortedParetoTrials = useMemo(() => {
const [i] = selectedObjectives;
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
}, [paretoTrials, selectedObjectives]);
// Create Pareto front line trace (2D only)
const createParetoLine = () => {
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
const [i, j] = selectedObjectives;
return {
type: 'scatter' as const,
mode: 'lines' as const,
name: 'Pareto Front',
x: sortedParetoTrials.map(t => getObjValue(t, i)),
y: sortedParetoTrials.map(t => getObjValue(t, j)),
line: {
color: '#10B981',
width: 2,
dash: 'dot'
},
hoverinfo: 'skip' as const,
showlegend: false
};
};
const traces = [
// Infeasible trials (background, red X)
...(showInfeasible && infeasibleTrials.length > 0 ? [
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
] : []),
// FEA trials (blue circles)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
// NN trials (purple diamonds)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
// Pareto front line (2D only)
createParetoLine(),
// Pareto front points (highlighted)
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
].filter(trace => trace && (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
// Dark mode color scheme
const colors = {
text: '#E5E7EB',
textMuted: '#9CA3AF',
grid: 'rgba(255,255,255,0.1)',
zeroline: 'rgba(255,255,255,0.2)',
legendBg: 'rgba(30,30,30,0.9)',
legendBorder: 'rgba(255,255,255,0.1)'
};
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: {
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
zaxis: {
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
bgcolor: 'transparent'
},
legend: {
x: 1,
y: 1,
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No trial data available
</div>
);
}
return (
<div className="w-full">
{/* Stats Bar */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-dark-300">Pareto:</span>
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="text-dark-300">FEA:</span>
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="text-dark-300">NN:</span>
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
</div>
{stats.infeasibleCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-dark-300">Infeasible:</span>
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '2d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '3d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
3D
</button>
</div>
)}
</div>
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-dark-400">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-dark-400 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-dark-400 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
</>
)}
</div>
</div>
<Plot
data={traces as any}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
height: 800,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
{/* Pareto Front Table for 2D view */}
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
<div className="mt-4 max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{sortedParetoTrials.slice(0, 10).map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, i).toExponential(4)}
</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, j).toExponential(4)}
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
(trial.source || trial.user_attrs?.source) === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source || trial.user_attrs?.source || 'FEA'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{sortedParetoTrials.length > 10 && (
<div className="text-center py-2 text-dark-500 text-xs">
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,247 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Run {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface PlotlyRunComparisonProps {
runs: Run[];
height?: number;
}
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
const chartData = useMemo(() => {
if (runs.length === 0) return null;
// Separate FEA and NN runs
const feaRuns = runs.filter(r => r.source === 'FEA');
const nnRuns = runs.filter(r => r.source === 'NN');
// Create bar chart for trial counts
const trialCountData = {
x: runs.map(r => r.name),
y: runs.map(r => r.trial_count),
type: 'bar' as const,
name: 'Trial Count',
marker: {
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
},
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
};
// Create line chart for best values
const bestValueData = {
x: runs.map(r => r.name),
y: runs.map(r => r.best_value),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Best Value',
yaxis: 'y2',
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
};
return { trialCountData, bestValueData, feaRuns, nnRuns };
}, [runs]);
// Calculate statistics
const stats = useMemo(() => {
if (runs.length === 0) return null;
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
// Calculate improvement from first FEA run to overall best
const feaRuns = runs.filter(r => r.source === 'FEA');
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
return {
totalTrials,
feaTrials,
nnTrials,
overallBest,
improvement,
totalRuns: runs.length,
feaRuns: runs.filter(r => r.source === 'FEA').length,
nnRuns: runs.filter(r => r.source === 'NN').length
};
}, [runs]);
if (!chartData || !stats) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No run data available
</div>
);
}
return (
<div className="space-y-4">
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Best Value</div>
<div className="text-xl font-bold text-green-400">
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Improvement</div>
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
{stats.improvement !== null ? (
<>
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
<Minus className="w-4 h-4" />}
{Math.abs(stats.improvement).toFixed(1)}%
</>
) : 'N/A'}
</div>
</div>
</div>
{/* Chart */}
<Plot
data={[chartData.trialCountData, chartData.bestValueData]}
layout={{
height,
margin: { l: 60, r: 60, t: 40, b: 100 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#9ca3af', size: 11 },
showlegend: true,
legend: {
orientation: 'h',
y: 1.12,
x: 0.5,
xanchor: 'center',
bgcolor: 'transparent'
},
xaxis: {
tickangle: -45,
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
tickfont: { size: 10 }
},
yaxis: {
title: { text: 'Trial Count' },
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false
},
yaxis2: {
title: { text: 'Best Value' },
overlaying: 'y',
side: 'right',
gridcolor: 'rgba(75, 85, 99, 0.1)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false,
tickformat: '.2e'
},
bargap: 0.3,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
}}
className="w-full"
useResizeHandler
style={{ width: '100%' }}
/>
{/* Runs Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Calculate duration if times available
let duration = '-';
if (run.first_trial && run.last_trial) {
const start = new Date(run.first_trial);
const end = new Date(run.last_trial);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
duration = `${diffMins}m`;
} else {
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
duration = `${hours}h ${mins}m`;
}
}
return (
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
<td className="py-2 px-3 text-right font-mono text-green-400">
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-right font-mono text-dark-300">
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-dark-400">{duration}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default PlotlyRunComparison;

View File

@@ -1,202 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
source?: 'FEA' | 'NN' | 'V10_FEA';
user_attrs?: Record<string, any>;
}
interface PlotlySurrogateQualityProps {
trials: TrialData[];
height?: number;
}
export function PlotlySurrogateQuality({
trials,
height = 400
}: PlotlySurrogateQualityProps) {
const { feaTrials, nnTrials, timeline } = useMemo(() => {
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
const nn = trials.filter(t => t.source === 'NN');
// Sort by trial number for timeline
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
// Calculate source distribution over time
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
let feaCount = 0;
let nnCount = 0;
sorted.forEach(t => {
if (t.source === 'NN') nnCount++;
else feaCount++;
timeline.push({
trial: t.trial_number,
feaCount,
nnCount
});
});
return {
feaTrials: fea,
nnTrials: nn,
timeline
};
}, [trials]);
if (nnTrials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No neural network evaluations in this study</p>
</div>
);
}
// Objective distribution by source
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
return (
<div className="space-y-6">
{/* Source Distribution Over Time */}
<Plot
data={[
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.feaCount),
type: 'scatter',
mode: 'lines',
name: 'FEA Cumulative',
line: { color: '#3b82f6', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(59, 130, 246, 0.2)'
},
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.nnCount),
type: 'scatter',
mode: 'lines',
name: 'NN Cumulative',
line: { color: '#a855f7', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(168, 85, 247, 0.2)'
}
]}
layout={{
title: {
text: 'Evaluation Source Over Time',
font: { color: '#fff', size: 14 }
},
height: height * 0.6,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Cumulative Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* Objective Distribution by Source */}
<Plot
data={[
{
x: feaObjectives,
type: 'histogram',
name: 'FEA',
marker: { color: 'rgba(59, 130, 246, 0.7)' },
opacity: 0.8
} as any,
{
x: nnObjectives,
type: 'histogram',
name: 'NN',
marker: { color: 'rgba(168, 85, 247, 0.7)' },
opacity: 0.8
} as any
]}
layout={{
title: {
text: 'Objective Distribution by Source',
font: { color: '#fff', size: 14 }
},
height: height * 0.5,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Objective Value', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
barmode: 'overlay',
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* FEA vs NN Best Values Comparison */}
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
<div className="text-xl font-mono text-blue-400">
{Math.min(...feaObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {feaObjectives.length} evaluations
</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
<div className="text-xl font-mono text-purple-400">
{Math.min(...nnObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {nnObjectives.length} predictions
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,217 +0,0 @@
# Plotly Chart Components
Interactive visualization components using Plotly.js for the Atomizer Dashboard.
## Overview
These components provide enhanced interactivity compared to Recharts:
- Native zoom, pan, and selection
- Export to PNG/SVG
- Hover tooltips with detailed information
- Brush filtering (parallel coordinates)
- 3D visualization support
## Components
### PlotlyParallelCoordinates
Multi-dimensional data visualization showing relationships between all variables.
```tsx
import { PlotlyParallelCoordinates } from '../components/plotly';
<PlotlyParallelCoordinates
trials={allTrials}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={450}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectives | Objective[] | Objective definitions |
| designVariables | DesignVariable[] | Design variable definitions |
| paretoFront | Trial[] | Pareto-optimal trials (optional) |
| height | number | Chart height in pixels |
**Features:**
- Drag on axes to filter data
- Double-click to reset filters
- Color coding: FEA (blue), NN (orange), Pareto (green)
### PlotlyParetoPlot
2D/3D scatter plot for Pareto front visualization.
```tsx
<PlotlyParetoPlot
trials={allTrials}
paretoFront={paretoFront}
objectives={studyMetadata.objectives}
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| paretoFront | Trial[] | Pareto-optimal trials |
| objectives | Objective[] | Objective definitions |
| height | number | Chart height in pixels |
**Features:**
- Toggle between 2D and 3D views
- Axis selector for multi-objective problems
- Click to select trials
- Hover for trial details
### PlotlyConvergencePlot
Optimization progress over trials.
```tsx
<PlotlyConvergencePlot
trials={allTrials}
objectiveIndex={0}
objectiveName="weighted_objective"
direction="minimize"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectiveIndex | number | Which objective to plot |
| objectiveName | string | Objective display name |
| direction | 'minimize' \| 'maximize' | Optimization direction |
| height | number | Chart height |
| showRangeSlider | boolean | Show zoom slider |
**Features:**
- Scatter points for each trial
- Best-so-far step line
- Range slider for zooming
- FEA vs NN differentiation
### PlotlyParameterImportance
Correlation-based parameter sensitivity analysis.
```tsx
<PlotlyParameterImportance
trials={allTrials}
designVariables={studyMetadata.design_variables}
objectiveIndex={0}
objectiveName="weighted_objective"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| designVariables | DesignVariable[] | Design variables |
| objectiveIndex | number | Which objective |
| objectiveName | string | Objective display name |
| height | number | Chart height |
**Features:**
- Horizontal bar chart of correlations
- Sort by importance or name
- Color: Red (positive), Green (negative)
- Pearson correlation coefficient
## Bundle Optimization
To minimize bundle size, we use:
1. **plotly.js-basic-dist**: Smaller bundle (~1MB vs 3.5MB)
- Includes: scatter, bar, parcoords
- Excludes: 3D plots, maps, animations
2. **Lazy Loading**: Components loaded on demand
```tsx
const PlotlyParetoPlot = lazy(() =>
import('./plotly/PlotlyParetoPlot')
.then(m => ({ default: m.PlotlyParetoPlot }))
);
```
3. **Code Splitting**: Vite config separates Plotly into its own chunk
```ts
manualChunks: {
plotly: ['plotly.js-basic-dist', 'react-plotly.js']
}
```
## Usage with Suspense
Always wrap Plotly components with Suspense:
```tsx
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot {...props} />
</Suspense>
```
## Type Definitions
```typescript
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
```
## Styling
Components use transparent backgrounds for dark theme compatibility:
- `paper_bgcolor: 'rgba(0,0,0,0)'`
- `plot_bgcolor: 'rgba(0,0,0,0)'`
- Font: Inter, system-ui, sans-serif
- Grid colors: Tailwind gray palette
## Export Options
All Plotly charts include a mode bar with:
- Download PNG
- Download SVG (via menu)
- Zoom, Pan, Reset
- Auto-scale
Configure export in the `config` prop:
```tsx
config={{
toImageButtonOptions: {
format: 'png',
filename: 'my_chart',
height: 600,
width: 1200,
scale: 2
}
}}
```

View File

@@ -1,15 +0,0 @@
/**
* Plotly-based interactive chart components
*
* These components provide enhanced interactivity compared to Recharts:
* - Native zoom/pan
* - Brush selection on axes
* - 3D views for multi-objective problems
* - Export to PNG/SVG
* - Detailed hover tooltips
*/
export { PlotlyParallelCoordinates } from './PlotlyParallelCoordinates';
export { PlotlyParetoPlot } from './PlotlyParetoPlot';
export { PlotlyConvergencePlot } from './PlotlyConvergencePlot';
export { PlotlyParameterImportance } from './PlotlyParameterImportance';

View File

@@ -0,0 +1,254 @@
/**
* StudioBuildDialog - Final dialog to name and build the study
*/
import React, { useState, useEffect } from 'react';
import { X, Loader2, FolderOpen, AlertCircle, CheckCircle, Sparkles, Play } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioBuildDialogProps {
draftId: string;
onClose: () => void;
onBuildComplete: (finalPath: string, finalName: string) => void;
}
interface Topic {
name: string;
study_count: number;
}
export const StudioBuildDialog: React.FC<StudioBuildDialogProps> = ({
draftId,
onClose,
onBuildComplete,
}) => {
const [studyName, setStudyName] = useState('');
const [topic, setTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [useNewTopic, setUseNewTopic] = useState(false);
const [topics, setTopics] = useState<Topic[]>([]);
const [isBuilding, setIsBuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// Load topics
useEffect(() => {
loadTopics();
}, []);
const loadTopics = async () => {
try {
const response = await intakeApi.listTopics();
setTopics(response.topics);
if (response.topics.length > 0) {
setTopic(response.topics[0].name);
}
} catch (err) {
console.error('Failed to load topics:', err);
}
};
// Validate study name
useEffect(() => {
const errors: string[] = [];
if (studyName.length > 0) {
if (studyName.length < 3) {
errors.push('Name must be at least 3 characters');
}
if (!/^[a-z0-9_]+$/.test(studyName)) {
errors.push('Use only lowercase letters, numbers, and underscores');
}
if (studyName.startsWith('draft_')) {
errors.push('Name cannot start with "draft_"');
}
}
setValidationErrors(errors);
}, [studyName]);
const handleBuild = async () => {
const finalTopic = useNewTopic ? newTopic : topic;
if (!studyName || !finalTopic) {
setError('Please provide both a study name and topic');
return;
}
if (validationErrors.length > 0) {
setError('Please fix validation errors');
return;
}
setIsBuilding(true);
setError(null);
try {
const response = await intakeApi.finalizeStudio(draftId, {
topic: finalTopic,
newName: studyName,
runBaseline: false,
});
onBuildComplete(response.final_path, response.final_name);
} catch (err) {
setError(err instanceof Error ? err.message : 'Build failed');
} finally {
setIsBuilding(false);
}
};
const isValid = studyName.length >= 3 &&
validationErrors.length === 0 &&
(topic || (useNewTopic && newTopic));
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-850 border border-dark-700 rounded-xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Build Study</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-dark-700 rounded text-dark-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Study Name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Study Name
</label>
<input
type="text"
value={studyName}
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
placeholder="my_optimization_study"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
/>
{validationErrors.length > 0 && (
<div className="mt-2 space-y-1">
{validationErrors.map((err, i) => (
<p key={i} className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{err}
</p>
))}
</div>
)}
{studyName.length >= 3 && validationErrors.length === 0 && (
<p className="mt-2 text-xs text-green-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Name is valid
</p>
)}
</div>
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder
</label>
{!useNewTopic && topics.length > 0 && (
<div className="space-y-2">
<select
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary-400"
>
{topics.map((t) => (
<option key={t.name} value={t.name}>
{t.name} ({t.study_count} studies)
</option>
))}
</select>
<button
onClick={() => setUseNewTopic(true)}
className="text-sm text-primary-400 hover:text-primary-300"
>
+ Create new topic
</button>
</div>
)}
{(useNewTopic || topics.length === 0) && (
<div className="space-y-2">
<input
type="text"
value={newTopic}
onChange={(e) => setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'))}
placeholder="NewTopic"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
/>
{topics.length > 0 && (
<button
onClick={() => setUseNewTopic(false)}
className="text-sm text-dark-400 hover:text-white"
>
Use existing topic
</button>
)}
</div>
)}
</div>
{/* Preview */}
<div className="p-3 bg-dark-700/50 rounded-lg">
<p className="text-xs text-dark-400 mb-1">Study will be created at:</p>
<p className="text-sm text-white font-mono flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-primary-400" />
studies/{useNewTopic ? newTopic || '...' : topic}/{studyName || '...'}
</p>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
disabled={isBuilding}
className="px-4 py-2 text-sm text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleBuild}
disabled={!isValid || isBuilding}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isBuilding ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Building...
</>
) : (
<>
<Play className="w-4 h-4" />
Build Study
</>
)}
</button>
</div>
</div>
</div>
);
};
export default StudioBuildDialog;

View File

@@ -0,0 +1,375 @@
/**
* StudioChat - Context-aware AI chat for Studio
*
* Uses the existing useChat hook to communicate with Claude via WebSocket.
* Injects model files and context documents into the conversation.
*/
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { Send, Loader2, Sparkles, FileText, Wifi, WifiOff, Bot, User, File, AlertCircle } from 'lucide-react';
import { useChat } from '../../hooks/useChat';
import { useSpecStore, useSpec } from '../../hooks/useSpecStore';
import { MarkdownRenderer } from '../MarkdownRenderer';
import { ToolCallCard } from '../chat/ToolCallCard';
interface StudioChatProps {
draftId: string;
contextFiles: string[];
contextContent: string;
modelFiles: string[];
onSpecUpdated: () => void;
}
export const StudioChat: React.FC<StudioChatProps> = ({
draftId,
contextFiles,
contextContent,
modelFiles,
onSpecUpdated,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState('');
const [hasInjectedContext, setHasInjectedContext] = useState(false);
// Get spec store for canvas updates
const spec = useSpec();
const { reloadSpec, setSpecFromWebSocket } = useSpecStore();
// Build canvas state with full context for Claude
const canvasState = useMemo(() => ({
nodes: [],
edges: [],
studyName: draftId,
studyPath: `_inbox/${draftId}`,
// Include file info for Claude context
modelFiles,
contextFiles,
contextContent: contextContent.substring(0, 50000), // Limit context size
}), [draftId, modelFiles, contextFiles, contextContent]);
// Use the chat hook with WebSocket
// Power mode gives Claude write permissions to modify the spec
const {
messages,
isThinking,
error,
isConnected,
sendMessage,
updateCanvasState,
} = useChat({
studyId: draftId,
mode: 'power', // Power mode = --dangerously-skip-permissions = can write files
useWebSocket: true,
canvasState,
onError: (err) => console.error('[StudioChat] Error:', err),
onSpecUpdated: (newSpec) => {
// Claude modified the spec - update the store directly
console.log('[StudioChat] Spec updated by Claude');
setSpecFromWebSocket(newSpec, draftId);
onSpecUpdated();
},
onCanvasModification: (modification) => {
// Claude wants to modify canvas - reload the spec
console.log('[StudioChat] Canvas modification:', modification);
reloadSpec();
onSpecUpdated();
},
});
// Update canvas state when context changes
useEffect(() => {
updateCanvasState(canvasState);
}, [canvasState, updateCanvasState]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input
useEffect(() => {
inputRef.current?.focus();
}, []);
// Build context summary for display
const contextSummary = useMemo(() => {
const parts: string[] = [];
if (modelFiles.length > 0) {
parts.push(`${modelFiles.length} model file${modelFiles.length > 1 ? 's' : ''}`);
}
if (contextFiles.length > 0) {
parts.push(`${contextFiles.length} context doc${contextFiles.length > 1 ? 's' : ''}`);
}
if (contextContent) {
parts.push(`${contextContent.length.toLocaleString()} chars context`);
}
return parts.join(', ');
}, [modelFiles, contextFiles, contextContent]);
const handleSend = () => {
if (!input.trim() || isThinking) return;
let messageToSend = input.trim();
// On first message, inject full context so Claude has everything it needs
if (!hasInjectedContext && (modelFiles.length > 0 || contextContent)) {
const contextParts: string[] = [];
// Add model files info
if (modelFiles.length > 0) {
contextParts.push(`**Model Files Uploaded:**\n${modelFiles.map(f => `- ${f}`).join('\n')}`);
}
// Add context document content (full text)
if (contextContent) {
contextParts.push(`**Context Documents Content:**\n\`\`\`\n${contextContent.substring(0, 30000)}\n\`\`\``);
}
// Add current spec state
if (spec) {
const dvCount = spec.design_variables?.length || 0;
const objCount = spec.objectives?.length || 0;
const extCount = spec.extractors?.length || 0;
if (dvCount > 0 || objCount > 0 || extCount > 0) {
contextParts.push(`**Current Configuration:** ${dvCount} design variables, ${objCount} objectives, ${extCount} extractors`);
}
}
if (contextParts.length > 0) {
messageToSend = `${contextParts.join('\n\n')}\n\n---\n\n**User Request:** ${messageToSend}`;
}
setHasInjectedContext(true);
}
sendMessage(messageToSend);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Welcome message for empty state
const showWelcome = messages.length === 0;
// Check if we have any context
const hasContext = modelFiles.length > 0 || contextContent.length > 0;
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b border-dark-700 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-400" />
<span className="font-medium text-white">Studio Assistant</span>
</div>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
isConnected
? 'text-green-400 bg-green-400/10'
: 'text-red-400 bg-red-400/10'
}`}>
{isConnected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Context indicator */}
{contextSummary && (
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1 text-amber-400 bg-amber-400/10 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
<span>{contextSummary}</span>
</div>
{hasContext && !hasInjectedContext && (
<span className="text-dark-500">Will be sent with first message</span>
)}
{hasInjectedContext && (
<span className="text-green-500">Context sent</span>
)}
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Welcome message with context awareness */}
{showWelcome && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 bg-dark-700 rounded-lg px-4 py-3 text-sm text-dark-100">
<MarkdownRenderer content={hasContext
? `I can see you've uploaded files. Here's what I have access to:
${modelFiles.length > 0 ? `**Model Files:** ${modelFiles.join(', ')}` : ''}
${contextContent ? `\n**Context Document:** ${contextContent.substring(0, 200)}...` : ''}
Tell me what you want to optimize and I'll help you configure the study!`
: `Welcome to Atomizer Studio! I'm here to help you configure your optimization study.
**What I can do:**
- Read your uploaded context documents
- Help set up design variables, objectives, and constraints
- Create extractors for physics outputs
- Suggest optimization strategies
Upload your model files and any requirements documents, then tell me what you want to optimize!`} />
</div>
</div>
)}
{/* File context display (only if we have files but no messages yet) */}
{showWelcome && modelFiles.length > 0 && (
<div className="bg-dark-800/50 rounded-lg p-3 border border-dark-700">
<p className="text-xs text-dark-400 mb-2 font-medium">Loaded Files:</p>
<div className="flex flex-wrap gap-2">
{modelFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-400 px-2 py-1 rounded">
<File className="w-3 h-3" />
{file}
</span>
))}
{contextFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-400 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Chat messages */}
{messages.map((msg) => {
const isAssistant = msg.role === 'assistant';
const isSystem = msg.role === 'system';
// System messages
if (isSystem) {
return (
<div key={msg.id} className="flex justify-center my-2">
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
{msg.content}
</div>
</div>
);
}
return (
<div
key={msg.id}
className={`flex gap-3 ${isAssistant ? '' : 'flex-row-reverse'}`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${
isAssistant
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-600 text-dark-300'
}`}
>
{isAssistant ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
</div>
{/* Message content */}
<div
className={`flex-1 max-w-[85%] rounded-lg px-4 py-3 text-sm ${
isAssistant
? 'bg-dark-700 text-dark-100'
: 'bg-primary-500 text-white ml-auto'
}`}
>
{isAssistant ? (
<>
{msg.content && <MarkdownRenderer content={msg.content} />}
{msg.isStreaming && !msg.content && (
<span className="text-dark-400">Thinking...</span>
)}
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{msg.toolCalls.map((tool, idx) => (
<ToolCallCard key={idx} toolCall={tool} />
))}
</div>
)}
</>
) : (
<span className="whitespace-pre-wrap">{msg.content}</span>
)}
</div>
</div>
);
})}
{/* Thinking indicator */}
{isThinking && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="bg-dark-700 rounded-lg px-4 py-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 text-primary-400 animate-spin" />
<span className="text-sm text-dark-300">Thinking...</span>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/20 text-red-400">
<AlertCircle className="w-4 h-4" />
</div>
<div className="flex-1 px-4 py-3 bg-red-500/10 rounded-lg text-sm text-red-400 border border-red-500/30">
{error}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-dark-700 flex-shrink-0">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isConnected ? "Ask about your optimization..." : "Connecting..."}
disabled={!isConnected}
rows={1}
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white placeholder-dark-400 resize-none focus:outline-none focus:border-primary-400 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isThinking || !isConnected}
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isThinking ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{!isConnected && (
<p className="text-xs text-dark-500 mt-1">
Waiting for connection to Claude...
</p>
)}
</div>
</div>
);
};
export default StudioChat;

View File

@@ -0,0 +1,117 @@
/**
* StudioContextFiles - Context document upload and display
*/
import React, { useState, useRef } from 'react';
import { FileText, Upload, Trash2, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioContextFilesProps {
draftId: string;
files: string[];
onUploadComplete: () => void;
}
export const StudioContextFiles: React.FC<StudioContextFilesProps> = ({
draftId,
files,
onUploadComplete,
}) => {
const [isUploading, setIsUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
if (selectedFiles.length === 0) return;
e.target.value = '';
setIsUploading(true);
try {
await intakeApi.uploadContextFiles(draftId, selectedFiles);
onUploadComplete();
} catch (err) {
console.error('Failed to upload context files:', err);
} finally {
setIsUploading(false);
}
};
const deleteFile = async (filename: string) => {
setDeleting(filename);
try {
await intakeApi.deleteContextFile(draftId, filename);
onUploadComplete();
} catch (err) {
console.error('Failed to delete context file:', err);
} finally {
setDeleting(null);
}
};
const getFileIcon = (_filename: string) => {
return <FileText className="w-3.5 h-3.5 text-amber-400" />;
};
return (
<div className="space-y-2">
{/* File List */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((name) => (
<div
key={name}
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm group"
>
{getFileIcon(name)}
<span className="text-dark-200 truncate flex-1">{name}</span>
<button
onClick={() => deleteFile(name)}
disabled={deleting === name}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 rounded text-red-400 transition-all"
>
{deleting === name ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Trash2 className="w-3 h-3" />
)}
</button>
</div>
))}
</div>
)}
{/* Upload Button */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
border border-dashed border-dark-600 text-dark-400 text-sm
hover:border-primary-400/50 hover:text-primary-400 hover:bg-primary-400/5
disabled:opacity-50 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? 'Uploading...' : 'Add context files'}
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default StudioContextFiles;

View File

@@ -0,0 +1,242 @@
/**
* StudioDropZone - Smart file drop zone for Studio
*
* Handles both model files (.sim, .prt, .fem) and context files (.pdf, .md, .txt)
*/
import React, { useState, useCallback, useRef } from 'react';
import { Upload, X, Loader2, AlertCircle, CheckCircle, File } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioDropZoneProps {
draftId: string;
type: 'model' | 'context';
files: string[];
onUploadComplete: () => void;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
const CONTEXT_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
export const StudioDropZone: React.FC<StudioDropZoneProps> = ({
draftId,
type,
files,
onUploadComplete,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validExtensions = type === 'model' ? MODEL_EXTENSIONS : CONTEXT_EXTENSIONS;
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!validExtensions.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
if (file.size > 500 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 500MB)' };
}
return { valid: true };
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
if (pendingFiles.some(f => f.file.name === file.name)) {
continue;
}
const validation = validateFile(file);
validFiles.push({
file,
status: validation.valid ? 'pending' : 'error',
message: validation.reason,
});
}
setPendingFiles(prev => [...prev, ...validFiles]);
}, [pendingFiles, validExtensions]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
addFiles(Array.from(e.dataTransfer.files));
}, [addFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== index));
};
const uploadFiles = async () => {
const toUpload = pendingFiles.filter(f => f.status === 'pending');
if (toUpload.length === 0) return;
setIsUploading(true);
try {
const uploadFn = type === 'model'
? intakeApi.uploadFiles
: intakeApi.uploadContextFiles;
const response = await uploadFn(draftId, toUpload.map(f => f.file));
const results = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setPendingFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = results.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
setTimeout(() => {
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
onUploadComplete();
}, 1000);
} catch (err) {
setPendingFiles(prev => prev.map(f =>
f.status === 'pending'
? { ...f, status: 'error', message: 'Upload failed' }
: f
));
} finally {
setIsUploading(false);
}
};
// Auto-upload when files are added
React.useEffect(() => {
const pending = pendingFiles.filter(f => f.status === 'pending');
if (pending.length > 0 && !isUploading) {
uploadFiles();
}
}, [pendingFiles, isUploading]);
return (
<div className="space-y-2">
{/* Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-lg p-4 cursor-pointer
transition-all duration-200 text-center
${isDragging
? 'border-primary-400 bg-primary-400/5'
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
}
`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-2
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
<Upload className="w-4 h-4" />
</div>
<p className="text-sm text-dark-300">
{isDragging ? 'Drop files here' : 'Drop or click to add'}
</p>
<p className="text-xs text-dark-500 mt-1">
{validExtensions.join(', ')}
</p>
</div>
{/* Existing Files */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((name, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm"
>
<File className="w-3.5 h-3.5 text-dark-400" />
<span className="text-dark-200 truncate flex-1">{name}</span>
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
</div>
))}
</div>
)}
{/* Pending Files */}
{pendingFiles.length > 0 && (
<div className="space-y-1">
{pendingFiles.map((f, i) => (
<div
key={i}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' : 'bg-dark-700'}`}
>
{f.status === 'pending' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
{f.status === 'uploading' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-3.5 h-3.5 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
<span className={`truncate flex-1 ${f.status === 'error' ? 'text-red-400' : 'text-dark-200'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
{f.status === 'pending' && (
<button onClick={(e) => { e.stopPropagation(); removeFile(i); }} className="p-0.5 hover:bg-white/10 rounded">
<X className="w-3 h-3 text-dark-400" />
</button>
)}
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={validExtensions.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default StudioDropZone;

View File

@@ -0,0 +1,172 @@
/**
* StudioParameterList - Display and add discovered parameters as design variables
*/
import React, { useState, useEffect } from 'react';
import { Plus, Check, SlidersHorizontal, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface Expression {
name: string;
value: number | null;
units: string | null;
is_candidate: boolean;
confidence: number;
}
interface StudioParameterListProps {
draftId: string;
onParameterAdded: () => void;
}
export const StudioParameterList: React.FC<StudioParameterListProps> = ({
draftId,
onParameterAdded,
}) => {
const [expressions, setExpressions] = useState<Expression[]>([]);
const [addedParams, setAddedParams] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Load expressions from spec introspection
useEffect(() => {
loadExpressions();
}, [draftId]);
const loadExpressions = async () => {
setLoading(true);
try {
const data = await intakeApi.getStudioDraft(draftId);
const introspection = (data.spec as any)?.model?.introspection;
if (introspection?.expressions) {
setExpressions(introspection.expressions);
// Check which are already added as DVs
const existingDVs = new Set<string>(
((data.spec as any)?.design_variables || []).map((dv: any) => dv.expression_name as string)
);
setAddedParams(existingDVs);
}
} catch (err) {
console.error('Failed to load expressions:', err);
} finally {
setLoading(false);
}
};
const addAsDesignVariable = async (expressionName: string) => {
setAdding(expressionName);
try {
await intakeApi.createDesignVariables(draftId, [expressionName]);
setAddedParams(prev => new Set([...prev, expressionName]));
onParameterAdded();
} catch (err) {
console.error('Failed to add design variable:', err);
} finally {
setAdding(null);
}
};
// Sort: candidates first, then by confidence
const sortedExpressions = [...expressions].sort((a, b) => {
if (a.is_candidate !== b.is_candidate) {
return b.is_candidate ? 1 : -1;
}
return (b.confidence || 0) - (a.confidence || 0);
});
// Show only candidates by default, with option to show all
const [showAll, setShowAll] = useState(false);
const displayExpressions = showAll
? sortedExpressions
: sortedExpressions.filter(e => e.is_candidate);
if (loading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 text-primary-400 animate-spin" />
</div>
);
}
if (expressions.length === 0) {
return (
<p className="text-xs text-dark-500 italic py-2">
No expressions found. Try running introspection.
</p>
);
}
const candidateCount = expressions.filter(e => e.is_candidate).length;
return (
<div className="space-y-2">
{/* Header with toggle */}
<div className="flex items-center justify-between text-xs text-dark-400">
<span>{candidateCount} candidates</span>
<button
onClick={() => setShowAll(!showAll)}
className="hover:text-primary-400 transition-colors"
>
{showAll ? 'Show candidates only' : `Show all (${expressions.length})`}
</button>
</div>
{/* Parameter List */}
<div className="space-y-1 max-h-48 overflow-y-auto">
{displayExpressions.map((expr) => {
const isAdded = addedParams.has(expr.name);
const isAdding = adding === expr.name;
return (
<div
key={expr.name}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
${isAdded ? 'bg-green-500/10' : 'bg-dark-700/50 hover:bg-dark-700'}
transition-colors`}
>
<SlidersHorizontal className="w-3.5 h-3.5 text-dark-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className={`block truncate ${isAdded ? 'text-green-400' : 'text-dark-200'}`}>
{expr.name}
</span>
{expr.value !== null && (
<span className="text-xs text-dark-500">
= {expr.value}{expr.units ? ` ${expr.units}` : ''}
</span>
)}
</div>
{isAdded ? (
<Check className="w-4 h-4 text-green-400 flex-shrink-0" />
) : (
<button
onClick={() => addAsDesignVariable(expr.name)}
disabled={isAdding}
className="p-1 hover:bg-primary-400/20 rounded text-primary-400 transition-colors disabled:opacity-50"
title="Add as design variable"
>
{isAdding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
</button>
)}
</div>
);
})}
</div>
{displayExpressions.length === 0 && (
<p className="text-xs text-dark-500 italic py-2">
No candidate parameters found. Click "Show all" to see all expressions.
</p>
)}
</div>
);
};
export default StudioParameterList;

View File

@@ -0,0 +1,11 @@
/**
* Studio Components Index
*
* Export all Studio-related components.
*/
export { StudioDropZone } from './StudioDropZone';
export { StudioParameterList } from './StudioParameterList';
export { StudioContextFiles } from './StudioContextFiles';
export { StudioChat } from './StudioChat';
export { StudioBuildDialog } from './StudioBuildDialog';

View File

@@ -3,3 +3,27 @@ export { useCanvasStore } from './useCanvasStore';
export type { OptimizationConfig } from './useCanvasStore';
export { useCanvasChat } from './useCanvasChat';
export { useIntentParser } from './useIntentParser';
// Spec Store (AtomizerSpec v2.0)
export {
useSpecStore,
useSpec,
useSpecLoading,
useSpecError,
useSpecValidation,
useSelectedNodeId,
useSelectedEdgeId,
useSpecHash,
useSpecIsDirty,
useDesignVariables,
useExtractors,
useObjectives,
useConstraints,
useCanvasEdges,
useSelectedNode,
} from './useSpecStore';
// WebSocket Sync
export { useSpecWebSocket } from './useSpecWebSocket';
export type { ConnectionStatus } from './useSpecWebSocket';
export { ConnectionStatusIndicator } from '../components/canvas/ConnectionStatusIndicator';

View File

@@ -11,12 +11,25 @@ export interface CanvasState {
studyPath?: string;
}
export interface CanvasModification {
action: 'add_node' | 'update_node' | 'remove_node' | 'add_edge' | 'remove_edge';
nodeType?: string;
nodeId?: string;
edgeId?: string;
data?: Record<string, any>;
source?: string;
target?: string;
position?: { x: number; y: number };
}
interface UseChatOptions {
studyId?: string | null;
mode?: ChatMode;
useWebSocket?: boolean;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
onCanvasModification?: (modification: CanvasModification) => void;
onSpecUpdated?: (spec: any) => void; // Called when Claude modifies the spec
}
interface ChatState {
@@ -35,6 +48,8 @@ export function useChat({
useWebSocket = true,
canvasState: initialCanvasState,
onError,
onCanvasModification,
onSpecUpdated,
}: UseChatOptions = {}) {
const [state, setState] = useState<ChatState>({
messages: [],
@@ -49,6 +64,23 @@ export function useChat({
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
// Sync mode prop changes to internal state (triggers WebSocket reconnect)
useEffect(() => {
if (mode !== state.mode) {
console.log(`[useChat] Mode prop changed from ${state.mode} to ${mode}, triggering reconnect`);
// Close existing WebSocket
wsRef.current?.close();
wsRef.current = null;
// Update internal state to trigger reconnect
setState((prev) => ({
...prev,
mode,
sessionId: null,
isConnected: false,
}));
}
}, [mode]);
const abortControllerRef = useRef<AbortController | null>(null);
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
const wsRef = useRef<WebSocket | null>(null);
@@ -82,9 +114,16 @@ export function useChat({
const data = await response.json();
setState((prev) => ({ ...prev, sessionId: data.session_id }));
// Connect WebSocket
// Connect WebSocket - use backend directly in dev mode
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/claude/sessions/${data.session_id}/ws`;
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Both modes use the same WebSocket - mode is handled by session config
// Power mode uses --dangerously-skip-permissions in CLI
// User mode uses --allowedTools to restrict access
const wsPath = `/api/claude/sessions/${data.session_id}/ws`;
const wsUrl = `${protocol}//${backendHost}${wsPath}`;
console.log(`[useChat] Connecting to WebSocket (${state.mode} mode): ${wsUrl}`);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
@@ -126,6 +165,9 @@ export function useChat({
// Handle WebSocket messages
const handleWebSocketMessage = useCallback((data: any) => {
// Debug: log all incoming WebSocket messages
console.log('[useChat] WebSocket message received:', data.type, data);
switch (data.type) {
case 'text':
currentMessageRef.current += data.content || '';
@@ -212,11 +254,51 @@ export function useChat({
// Canvas state was updated - could show notification
break;
case 'canvas_modification':
// Assistant wants to modify the canvas (from MCP tools in user mode)
console.log('[useChat] Received canvas_modification:', data.modification);
if (onCanvasModification && data.modification) {
console.log('[useChat] Calling onCanvasModification callback');
onCanvasModification(data.modification);
} else {
console.warn('[useChat] canvas_modification received but no handler or modification:', {
hasCallback: !!onCanvasModification,
modification: data.modification
});
}
break;
case 'spec_updated':
// Assistant modified the spec - we receive the full updated spec
console.log('[useChat] Spec updated by assistant:', data.tool, data.reason);
if (onSpecUpdated && data.spec) {
// Directly update the canvas with the new spec
onSpecUpdated(data.spec);
}
break;
case 'spec_modified':
// Legacy: Assistant modified the spec directly (from power mode write tools)
console.log('[useChat] Spec was modified by assistant (legacy):', data.tool, data.changes);
// Treat this as a canvas modification to trigger reload
if (onCanvasModification) {
// Create a synthetic modification event to trigger canvas refresh
onCanvasModification({
action: 'add_node', // Use add_node as it triggers refresh
data: {
_refresh: true,
tool: data.tool,
changes: data.changes,
},
});
}
break;
case 'pong':
// Heartbeat response - ignore
break;
}
}, [onError]);
}, [onError, onCanvasModification]);
// Switch mode (requires new session)
const switchMode = useCallback(async (newMode: ChatMode) => {
@@ -462,6 +544,18 @@ export function useChat({
}
}, [useWebSocket]);
// Notify backend when user edits canvas (so Claude sees the changes)
const notifyCanvasEdit = useCallback((spec: any) => {
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'canvas_edit',
spec: spec,
})
);
}
}, [useWebSocket]);
return {
messages: state.messages,
isThinking: state.isThinking,
@@ -475,5 +569,6 @@ export function useChat({
cancelRequest,
switchMode,
updateCanvasState,
notifyCanvasEdit,
};
}

View File

@@ -0,0 +1,349 @@
/**
* Hook for Claude Code CLI integration
*
* Connects to backend that spawns actual Claude Code CLI processes.
* This gives full power: file editing, command execution, etc.
*
* Unlike useChat (which uses MCP tools), this hook:
* - Spawns actual Claude Code CLI in the backend
* - Has full file system access
* - Can edit files directly (not just return instructions)
* - Uses Opus 4.5 model
* - Has all Claude Code capabilities
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Message } from '../components/chat/ChatMessage';
import { useCanvasStore } from './useCanvasStore';
export interface CanvasState {
nodes: any[];
edges: any[];
studyName?: string;
studyPath?: string;
}
interface UseClaudeCodeOptions {
studyId?: string | null;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
onCanvasRefresh?: (studyId: string) => void;
}
interface ClaudeCodeState {
messages: Message[];
isThinking: boolean;
error: string | null;
sessionId: string | null;
isConnected: boolean;
workingDir: string | null;
}
export function useClaudeCode({
studyId,
canvasState: initialCanvasState,
onError,
onCanvasRefresh,
}: UseClaudeCodeOptions = {}) {
const [state, setState] = useState<ClaudeCodeState>({
messages: [],
isThinking: false,
error: null,
sessionId: null,
isConnected: false,
workingDir: null,
});
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
const wsRef = useRef<WebSocket | null>(null);
const currentMessageRef = useRef<string>('');
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
// Keep canvas state in sync with prop changes
useEffect(() => {
if (initialCanvasState) {
canvasStateRef.current = initialCanvasState;
}
}, [initialCanvasState]);
// Get canvas store for auto-refresh
const { loadFromConfig } = useCanvasStore();
// Connect to Claude Code WebSocket
useEffect(() => {
const connect = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// In development, connect directly to backend (bypass Vite proxy for WebSockets)
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Use study-specific endpoint if studyId provided
const wsUrl = studyId
? `${protocol}//${backendHost}/api/claude-code/ws/${encodeURIComponent(studyId)}`
: `${protocol}//${backendHost}/api/claude-code/ws`;
console.log('[ClaudeCode] Connecting to:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[ClaudeCode] Connected');
setState((prev) => ({ ...prev, isConnected: true, error: null }));
reconnectAttempts.current = 0;
// If no studyId in URL, send init message
if (!studyId) {
ws.send(JSON.stringify({ type: 'init', study_id: null }));
}
};
ws.onclose = () => {
console.log('[ClaudeCode] Disconnected');
setState((prev) => ({ ...prev, isConnected: false }));
// Attempt reconnection
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++;
console.log(`[ClaudeCode] Reconnecting... attempt ${reconnectAttempts.current}`);
setTimeout(connect, 2000 * reconnectAttempts.current);
}
};
ws.onerror = (event) => {
console.error('[ClaudeCode] WebSocket error:', event);
setState((prev) => ({ ...prev, isConnected: false }));
onError?.('Claude Code connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('[ClaudeCode] Failed to parse message:', e);
}
};
wsRef.current = ws;
};
connect();
return () => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection on unmount
wsRef.current?.close();
wsRef.current = null;
};
}, [studyId]);
// Handle WebSocket messages
const handleWebSocketMessage = useCallback(
(data: any) => {
switch (data.type) {
case 'initialized':
console.log('[ClaudeCode] Session initialized:', data.session_id);
setState((prev) => ({
...prev,
sessionId: data.session_id,
workingDir: data.working_dir || null,
}));
break;
case 'text':
currentMessageRef.current += data.content || '';
setState((prev) => ({
...prev,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, content: currentMessageRef.current }
: msg
),
}));
break;
case 'done':
setState((prev) => ({
...prev,
isThinking: false,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, isStreaming: false }
: msg
),
}));
currentMessageRef.current = '';
break;
case 'error':
console.error('[ClaudeCode] Error:', data.content);
setState((prev) => ({
...prev,
isThinking: false,
error: data.content || 'Unknown error',
}));
onError?.(data.content || 'Unknown error');
currentMessageRef.current = '';
break;
case 'refresh_canvas':
// Claude made file changes - trigger canvas refresh
console.log('[ClaudeCode] Canvas refresh requested:', data.reason);
if (data.study_id) {
onCanvasRefresh?.(data.study_id);
reloadCanvasFromStudy(data.study_id);
}
break;
case 'canvas_updated':
console.log('[ClaudeCode] Canvas state updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log('[ClaudeCode] Unknown message type:', data.type);
}
},
[onError, onCanvasRefresh]
);
// Reload canvas from study config
const reloadCanvasFromStudy = useCallback(
async (studyIdToReload: string) => {
try {
console.log('[ClaudeCode] Reloading canvas for study:', studyIdToReload);
// Fetch fresh config from backend
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyIdToReload)}/config`);
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status}`);
}
const data = await response.json();
const config = data.config; // API returns { config: ..., path: ..., study_id: ... }
// Reload canvas with new config
loadFromConfig(config);
// Add system message about refresh
const refreshMessage: Message = {
id: `msg_${Date.now()}_refresh`,
role: 'system',
content: `Canvas refreshed with latest changes from ${studyIdToReload}`,
timestamp: new Date(),
};
setState((prev) => ({
...prev,
messages: [...prev.messages, refreshMessage],
}));
} catch (error) {
console.error('[ClaudeCode] Failed to reload canvas:', error);
}
},
[loadFromConfig]
);
const generateMessageId = () => {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || state.isThinking) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected to Claude Code');
return;
}
// Add user message
const userMessage: Message = {
id: generateMessageId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};
// Add assistant message placeholder
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage, assistantMessage],
isThinking: true,
error: null,
}));
// Reset current message tracking
currentMessageRef.current = '';
// Send message via WebSocket with canvas state
wsRef.current.send(
JSON.stringify({
type: 'message',
content: content.trim(),
canvas_state: canvasStateRef.current || undefined,
})
);
},
[state.isThinking, onError]
);
const clearMessages = useCallback(() => {
setState((prev) => ({
...prev,
messages: [],
error: null,
}));
currentMessageRef.current = '';
}, []);
// Update canvas state (call this when canvas changes)
const updateCanvasState = useCallback((newCanvasState: CanvasState | null) => {
canvasStateRef.current = newCanvasState;
// Also send to backend to update context
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'set_canvas',
canvas_state: newCanvasState,
})
);
}
}, []);
// Send ping to keep connection alive
useEffect(() => {
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Every 30 seconds
return () => clearInterval(pingInterval);
}, []);
return {
messages: state.messages,
isThinking: state.isThinking,
error: state.error,
sessionId: state.sessionId,
isConnected: state.isConnected,
workingDir: state.workingDir,
sendMessage,
clearMessages,
updateCanvasState,
reloadCanvasFromStudy,
};
}

View File

@@ -0,0 +1,335 @@
/**
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
*
* This hook provides:
* - Real-time trial updates (no polling needed)
* - Best trial tracking
* - Progress tracking
* - Error detection and reporting
* - Integration with panel store for error display
* - Automatic reconnection
*
* Usage:
* ```tsx
* const {
* isConnected,
* progress,
* bestTrial,
* recentTrials,
* status
* } = useOptimizationStream(studyId);
* ```
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import { usePanelStore } from './usePanelStore';
// ============================================================================
// Types
// ============================================================================
export interface TrialData {
trial_number: number;
trial_num: number;
objective: number | null;
values: number[];
params: Record<string, number>;
user_attrs: Record<string, unknown>;
source: 'FEA' | 'NN' | string;
start_time: string;
end_time: string;
study_name: string;
constraint_satisfied: boolean;
}
export interface ProgressData {
current: number;
total: number;
percentage: number;
fea_count: number;
nn_count: number;
timestamp: string;
}
export interface BestTrialData {
trial_number: number;
value: number;
params: Record<string, number>;
improvement: number;
}
export interface ParetoData {
pareto_front: Array<{
trial_number: number;
values: number[];
params: Record<string, number>;
constraint_satisfied: boolean;
source: string;
}>;
count: number;
}
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
export interface OptimizationStreamState {
isConnected: boolean;
status: OptimizationStatus;
progress: ProgressData | null;
bestTrial: BestTrialData | null;
recentTrials: TrialData[];
paretoFront: ParetoData | null;
lastUpdate: number | null;
error: string | null;
}
// ============================================================================
// Hook
// ============================================================================
interface UseOptimizationStreamOptions {
/** Maximum number of recent trials to keep */
maxRecentTrials?: number;
/** Callback when a new trial completes */
onTrialComplete?: (trial: TrialData) => void;
/** Callback when a new best is found */
onNewBest?: (best: BestTrialData) => void;
/** Callback on progress update */
onProgress?: (progress: ProgressData) => void;
/** Whether to auto-report errors to the error panel */
autoReportErrors?: boolean;
}
export function useOptimizationStream(
studyId: string | null | undefined,
options: UseOptimizationStreamOptions = {}
) {
const {
maxRecentTrials = 20,
onTrialComplete,
onNewBest,
onProgress,
autoReportErrors = true,
} = options;
// Panel store for error reporting
const { addError } = usePanelStore();
// State
const [state, setState] = useState<OptimizationStreamState>({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
// Track last error timestamp to avoid duplicates
const lastErrorTime = useRef<number>(0);
// Build WebSocket URL
const socketUrl = studyId
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
import.meta.env.DEV ? 'localhost:8001' : window.location.host
}/api/ws/optimization/${encodeURIComponent(studyId)}`
: null;
// WebSocket connection
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
shouldReconnect: () => true,
reconnectAttempts: 10,
reconnectInterval: 3000,
onOpen: () => {
console.log('[OptStream] Connected to optimization stream');
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
},
onClose: () => {
console.log('[OptStream] Disconnected from optimization stream');
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
},
onError: (event) => {
console.error('[OptStream] WebSocket error:', event);
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
},
});
// Update connection status
useEffect(() => {
const statusMap: Record<ReadyState, OptimizationStatus> = {
[ReadyState.CONNECTING]: 'connecting',
[ReadyState.OPEN]: 'connected',
[ReadyState.CLOSING]: 'disconnected',
[ReadyState.CLOSED]: 'disconnected',
[ReadyState.UNINSTANTIATED]: 'disconnected',
};
setState(prev => ({
...prev,
isConnected: readyState === ReadyState.OPEN,
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
? prev.status
: statusMap[readyState] || 'disconnected',
}));
}, [readyState]);
// Process incoming messages
useEffect(() => {
if (!lastMessage?.data) return;
try {
const message = JSON.parse(lastMessage.data);
const { type, data } = message;
switch (type) {
case 'connected':
console.log('[OptStream] Connection confirmed:', data.message);
break;
case 'trial_completed':
handleTrialComplete(data as TrialData);
break;
case 'new_best':
handleNewBest(data as BestTrialData);
break;
case 'progress':
handleProgress(data as ProgressData);
break;
case 'pareto_update':
handleParetoUpdate(data as ParetoData);
break;
case 'heartbeat':
case 'pong':
// Keep-alive messages
break;
case 'error':
handleError(data);
break;
default:
console.log('[OptStream] Unknown message type:', type, data);
}
} catch (e) {
console.error('[OptStream] Failed to parse message:', e);
}
}, [lastMessage]);
// Handler functions
const handleTrialComplete = useCallback((trial: TrialData) => {
setState(prev => {
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
return {
...prev,
recentTrials: newTrials,
lastUpdate: Date.now(),
status: 'running',
};
});
onTrialComplete?.(trial);
}, [maxRecentTrials, onTrialComplete]);
const handleNewBest = useCallback((best: BestTrialData) => {
setState(prev => ({
...prev,
bestTrial: best,
lastUpdate: Date.now(),
}));
onNewBest?.(best);
}, [onNewBest]);
const handleProgress = useCallback((progress: ProgressData) => {
setState(prev => {
// Determine status based on progress
let status: OptimizationStatus = prev.status;
if (progress.current > 0 && progress.current < progress.total) {
status = 'running';
} else if (progress.current >= progress.total) {
status = 'completed';
}
return {
...prev,
progress,
status,
lastUpdate: Date.now(),
};
});
onProgress?.(progress);
}, [onProgress]);
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
setState(prev => ({
...prev,
paretoFront: pareto,
lastUpdate: Date.now(),
}));
}, []);
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
const now = Date.now();
// Avoid duplicate errors within 5 seconds
if (now - lastErrorTime.current < 5000) return;
lastErrorTime.current = now;
setState(prev => ({
...prev,
error: errorData.message,
status: 'failed',
}));
if (autoReportErrors) {
addError({
type: 'system_error',
message: errorData.message,
details: errorData.details,
trial: errorData.trial,
recoverable: true,
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
timestamp: now,
});
}
}, [autoReportErrors, addError]);
// Send ping to keep connection alive
useEffect(() => {
if (readyState !== ReadyState.OPEN) return;
const interval = setInterval(() => {
sendMessage(JSON.stringify({ type: 'ping' }));
}, 25000); // Ping every 25 seconds
return () => clearInterval(interval);
}, [readyState, sendMessage]);
// Reset state when study changes
useEffect(() => {
setState({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
}, [studyId]);
return {
...state,
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
};
}
export default useOptimizationStream;

View File

@@ -0,0 +1,375 @@
/**
* usePanelStore - Centralized state management for canvas panels
*
* This store manages the visibility and state of all panels in the canvas view.
* Panels persist their state even when the user clicks elsewhere on the canvas.
*
* Panel Types:
* - introspection: Model introspection results (floating, draggable)
* - validation: Spec validation errors/warnings (floating)
* - results: Trial results details (floating)
* - error: Error display with recovery options (floating)
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// ============================================================================
// Types
// ============================================================================
export interface IntrospectionData {
filePath: string;
studyId?: string;
selectedFile?: string;
result?: Record<string, unknown>;
isLoading?: boolean;
error?: string | null;
}
export interface ValidationError {
code: string;
severity: 'error' | 'warning';
path: string;
message: string;
suggestion?: string;
nodeId?: string;
}
export interface ValidationData {
valid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
checkedAt: number;
}
export interface OptimizationError {
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown';
trial?: number;
message: string;
details?: string;
recoverable: boolean;
suggestions: string[];
timestamp: number;
}
export interface TrialResultData {
trialNumber: number;
params: Record<string, number>;
objectives: Record<string, number>;
constraints?: Record<string, { value: number; feasible: boolean }>;
isFeasible: boolean;
isBest: boolean;
timestamp: number;
}
export interface PanelPosition {
x: number;
y: number;
}
export interface PanelState {
open: boolean;
position?: PanelPosition;
minimized?: boolean;
}
export interface IntrospectionPanelState extends PanelState {
data?: IntrospectionData;
}
export interface ValidationPanelState extends PanelState {
data?: ValidationData;
}
export interface ErrorPanelState extends PanelState {
errors: OptimizationError[];
}
export interface ResultsPanelState extends PanelState {
data?: TrialResultData;
}
// ============================================================================
// Store Interface
// ============================================================================
interface PanelStore {
// Panel states
introspection: IntrospectionPanelState;
validation: ValidationPanelState;
error: ErrorPanelState;
results: ResultsPanelState;
// Generic panel actions
openPanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
closePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
togglePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
minimizePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
setPanelPosition: (panel: 'introspection' | 'validation' | 'error' | 'results', position: PanelPosition) => void;
// Introspection-specific actions
setIntrospectionData: (data: IntrospectionData) => void;
updateIntrospectionResult: (result: Record<string, unknown>) => void;
setIntrospectionLoading: (loading: boolean) => void;
setIntrospectionError: (error: string | null) => void;
setIntrospectionFile: (fileName: string) => void;
// Validation-specific actions
setValidationData: (data: ValidationData) => void;
clearValidation: () => void;
// Error-specific actions
addError: (error: OptimizationError) => void;
clearErrors: () => void;
dismissError: (timestamp: number) => void;
// Results-specific actions
setTrialResult: (data: TrialResultData) => void;
clearTrialResult: () => void;
// Utility
closeAllPanels: () => void;
hasOpenPanels: () => boolean;
}
// ============================================================================
// Default States
// ============================================================================
const defaultIntrospection: IntrospectionPanelState = {
open: false,
position: { x: 100, y: 100 },
minimized: false,
data: undefined,
};
const defaultValidation: ValidationPanelState = {
open: false,
position: { x: 150, y: 150 },
minimized: false,
data: undefined,
};
const defaultError: ErrorPanelState = {
open: false,
position: { x: 200, y: 100 },
minimized: false,
errors: [],
};
const defaultResults: ResultsPanelState = {
open: false,
position: { x: 250, y: 150 },
minimized: false,
data: undefined,
};
// ============================================================================
// Store Implementation
// ============================================================================
export const usePanelStore = create<PanelStore>()(
persist(
(set, get) => ({
// Initial states
introspection: defaultIntrospection,
validation: defaultValidation,
error: defaultError,
results: defaultResults,
// Generic panel actions
openPanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: true, minimized: false }
})),
closePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: false }
})),
togglePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: !state[panel].open, minimized: false }
})),
minimizePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], minimized: !state[panel].minimized }
})),
setPanelPosition: (panel, position) => set((state) => ({
[panel]: { ...state[panel], position }
})),
// Introspection actions
setIntrospectionData: (data) => set((state) => ({
introspection: {
...state.introspection,
open: true,
data
}
})),
updateIntrospectionResult: (result) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, result, isLoading: false, error: null }
: undefined
}
})),
setIntrospectionLoading: (loading) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, isLoading: loading }
: undefined
}
})),
setIntrospectionError: (error) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, error, isLoading: false }
: undefined
}
})),
setIntrospectionFile: (fileName) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, selectedFile: fileName }
: undefined
}
})),
// Validation actions
setValidationData: (data) => set((state) => ({
validation: {
...state.validation,
open: true,
data
}
})),
clearValidation: () => set((state) => ({
validation: {
...state.validation,
data: undefined
}
})),
// Error actions
addError: (error) => set((state) => ({
error: {
...state.error,
open: true,
errors: [...state.error.errors, error]
}
})),
clearErrors: () => set((state) => ({
error: {
...state.error,
errors: [],
open: false
}
})),
dismissError: (timestamp) => set((state) => {
const newErrors = state.error.errors.filter(e => e.timestamp !== timestamp);
return {
error: {
...state.error,
errors: newErrors,
open: newErrors.length > 0
}
};
}),
// Results actions
setTrialResult: (data) => set((state) => ({
results: {
...state.results,
open: true,
data
}
})),
clearTrialResult: () => set((state) => ({
results: {
...state.results,
data: undefined,
open: false
}
})),
// Utility
closeAllPanels: () => set({
introspection: { ...get().introspection, open: false },
validation: { ...get().validation, open: false },
error: { ...get().error, open: false },
results: { ...get().results, open: false },
}),
hasOpenPanels: () => {
const state = get();
return state.introspection.open ||
state.validation.open ||
state.error.open ||
state.results.open;
},
}),
{
name: 'atomizer-panel-store',
// Only persist certain fields (not loading states or errors)
partialize: (state) => ({
introspection: {
position: state.introspection.position,
// Don't persist open state - start fresh each session
},
validation: {
position: state.validation.position,
},
error: {
position: state.error.position,
},
results: {
position: state.results.position,
},
}),
}
)
);
// ============================================================================
// Selector Hooks (for convenience)
// ============================================================================
export const useIntrospectionPanel = () => usePanelStore((state) => state.introspection);
export const useValidationPanel = () => usePanelStore((state) => state.validation);
export const useErrorPanel = () => usePanelStore((state) => state.error);
export const useResultsPanel = () => usePanelStore((state) => state.results);
// Actions
export const usePanelActions = () => usePanelStore((state) => ({
openPanel: state.openPanel,
closePanel: state.closePanel,
togglePanel: state.togglePanel,
minimizePanel: state.minimizePanel,
setPanelPosition: state.setPanelPosition,
setIntrospectionData: state.setIntrospectionData,
updateIntrospectionResult: state.updateIntrospectionResult,
setIntrospectionLoading: state.setIntrospectionLoading,
setIntrospectionError: state.setIntrospectionError,
setIntrospectionFile: state.setIntrospectionFile,
setValidationData: state.setValidationData,
clearValidation: state.clearValidation,
addError: state.addError,
clearErrors: state.clearErrors,
dismissError: state.dismissError,
setTrialResult: state.setTrialResult,
clearTrialResult: state.clearTrialResult,
closeAllPanels: state.closeAllPanels,
}));

View File

@@ -0,0 +1,156 @@
/**
* useResizablePanel - Hook for creating resizable panels with persistence
*
* Features:
* - Drag to resize
* - Min/max constraints
* - localStorage persistence
* - Double-click to reset to default
*/
import { useState, useCallback, useEffect, useRef } from 'react';
export interface ResizablePanelConfig {
/** Unique key for localStorage persistence */
storageKey: string;
/** Default width in pixels */
defaultWidth: number;
/** Minimum width in pixels */
minWidth: number;
/** Maximum width in pixels */
maxWidth: number;
/** Side of the panel ('left' or 'right') - affects resize direction */
side: 'left' | 'right';
}
export interface ResizablePanelState {
/** Current width in pixels */
width: number;
/** Whether user is currently dragging */
isDragging: boolean;
/** Start drag handler - attach to resize handle mousedown */
startDrag: (e: React.MouseEvent) => void;
/** Reset to default width */
resetWidth: () => void;
/** Set width programmatically */
setWidth: (width: number) => void;
}
const STORAGE_PREFIX = 'atomizer-panel-';
function getStoredWidth(key: string, defaultWidth: number): number {
if (typeof window === 'undefined') return defaultWidth;
try {
const stored = localStorage.getItem(STORAGE_PREFIX + key);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed)) return parsed;
}
} catch {
// localStorage not available
}
return defaultWidth;
}
function storeWidth(key: string, width: number): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_PREFIX + key, String(width));
} catch {
// localStorage not available
}
}
export function useResizablePanel(config: ResizablePanelConfig): ResizablePanelState {
const { storageKey, defaultWidth, minWidth, maxWidth, side } = config;
// Initialize from localStorage
const [width, setWidthState] = useState(() => {
const stored = getStoredWidth(storageKey, defaultWidth);
return Math.max(minWidth, Math.min(maxWidth, stored));
});
const [isDragging, setIsDragging] = useState(false);
// Track initial position for drag calculation
const dragStartRef = useRef<{ x: number; width: number } | null>(null);
// Clamp width within bounds
const clampWidth = useCallback((w: number) => {
return Math.max(minWidth, Math.min(maxWidth, w));
}, [minWidth, maxWidth]);
// Set width with clamping and persistence
const setWidth = useCallback((newWidth: number) => {
const clamped = clampWidth(newWidth);
setWidthState(clamped);
storeWidth(storageKey, clamped);
}, [clampWidth, storageKey]);
// Reset to default
const resetWidth = useCallback(() => {
setWidth(defaultWidth);
}, [defaultWidth, setWidth]);
// Start drag handler
const startDrag = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, width };
}, [width]);
// Handle mouse move during drag
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const delta = e.clientX - dragStartRef.current.x;
// For left panels, positive delta increases width
// For right panels, negative delta increases width
const newWidth = side === 'left'
? dragStartRef.current.width + delta
: dragStartRef.current.width - delta;
setWidthState(clampWidth(newWidth));
};
const handleMouseUp = () => {
if (dragStartRef.current) {
// Persist the final width
storeWidth(storageKey, width);
}
setIsDragging(false);
dragStartRef.current = null;
};
// Add listeners to document for smooth dragging
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Change cursor globally during drag
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, side, clampWidth, storageKey, width]);
return {
width,
isDragging,
startDrag,
resetWidth,
setWidth,
};
}
export default useResizablePanel;

View File

@@ -0,0 +1,121 @@
/**
* useSpecDraft (S2 Draft + Publish)
*
* Local autosave for AtomizerSpec so users don't lose work.
* "Publish" still uses useSpecStore.saveSpec() to write atomizer_spec.json.
*
* NOTE: This is a partial S2 implementation because the current store
* still patches the backend during edits. This draft layer still provides:
* - crash/refresh protection
* - explicit restore/discard prompt
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AtomizerSpec } from '../types/atomizer-spec';
const draftKey = (studyId: string) => `atomizer:draft:${studyId}`;
type DraftPayload = {
spec: AtomizerSpec;
baseHash: string | null;
updatedAt: number;
};
export function useSpecDraft(params: {
studyId: string | null | undefined;
spec: AtomizerSpec | null | undefined;
serverHash: string | null | undefined;
enabled?: boolean;
}) {
const { studyId, spec, serverHash, enabled = true } = params;
const [hasDraft, setHasDraft] = useState(false);
const [draft, setDraft] = useState<DraftPayload | null>(null);
// Debounce writes
const writeTimer = useRef<number | null>(null);
const key = useMemo(() => (studyId ? draftKey(studyId) : null), [studyId]);
const loadDraft = useCallback(() => {
if (!enabled || !key) return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as DraftPayload;
if (!parsed?.spec) return null;
return parsed;
} catch {
return null;
}
}, [enabled, key]);
const discardDraft = useCallback(() => {
if (!enabled || !key) return;
localStorage.removeItem(key);
setHasDraft(false);
setDraft(null);
}, [enabled, key]);
const saveDraftNow = useCallback(
(payload: DraftPayload) => {
if (!enabled || !key) return;
try {
localStorage.setItem(key, JSON.stringify(payload));
setHasDraft(true);
setDraft(payload);
} catch {
// ignore storage failures
}
},
[enabled, key]
);
// Load draft on study change
useEffect(() => {
if (!enabled || !key) return;
const existing = loadDraft();
if (existing) {
setHasDraft(true);
setDraft(existing);
} else {
setHasDraft(false);
setDraft(null);
}
}, [enabled, key, loadDraft]);
// Autosave whenever spec changes
useEffect(() => {
if (!enabled || !key) return;
if (!studyId || !spec) return;
// Clear existing debounce
if (writeTimer.current) {
window.clearTimeout(writeTimer.current);
writeTimer.current = null;
}
writeTimer.current = window.setTimeout(() => {
saveDraftNow({ spec, baseHash: serverHash ?? null, updatedAt: Date.now() });
}, 750);
return () => {
if (writeTimer.current) {
window.clearTimeout(writeTimer.current);
writeTimer.current = null;
}
};
}, [enabled, key, studyId, spec, serverHash, saveDraftNow]);
return {
hasDraft,
draft,
discardDraft,
reloadDraft: () => {
const d = loadDraft();
setDraft(d);
setHasDraft(Boolean(d));
return d;
},
};
}

View File

@@ -63,6 +63,9 @@ interface SpecStoreActions {
// WebSocket integration - set spec directly without API call
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
// Local draft integration (S2) - set spec locally (no API call) and mark dirty
setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => void;
// Full spec operations
saveSpec: (spec: AtomizerSpec) => Promise<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
@@ -402,6 +405,20 @@ export const useSpecStore = create<SpecStore>()(
});
},
// Set spec locally as a draft (no API call). This is used by DraftManager (S2).
// Marks the spec as dirty to indicate "not published".
setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => {
const currentStudyId = studyId || get().studyId;
console.log('[useSpecStore] Setting spec from local draft:', spec.meta?.study_name);
set({
spec,
studyId: currentStudyId,
isLoading: false,
isDirty: true,
error: null,
});
},
// =====================================================================
// Full Spec Operations
// =====================================================================

View File

@@ -16,7 +16,7 @@
import { useEffect, useRef } from 'react';
import { useUndoRedo, UndoRedoResult } from './useUndoRedo';
import { useSpecStore, useSpec, useSpecIsDirty } from './useSpecStore';
import { useSpecStore, useSpec } from './useSpecStore';
import { AtomizerSpec } from '../types/atomizer-spec';
const STORAGE_KEY_PREFIX = 'atomizer-spec-history-';
@@ -28,7 +28,6 @@ export interface SpecUndoRedoResult extends UndoRedoResult<AtomizerSpec | null>
export function useSpecUndoRedo(): SpecUndoRedoResult {
const spec = useSpec();
const isDirty = useSpecIsDirty();
const studyId = useSpecStore((state) => state.studyId);
const lastSpecRef = useRef<AtomizerSpec | null>(null);
@@ -56,13 +55,21 @@ export function useSpecUndoRedo(): SpecUndoRedoResult {
},
});
// Record snapshot when spec changes (and is dirty)
// Record snapshot when spec changes
// Note: We removed the isDirty check because with auto-save, isDirty is always false
// after the API call completes. Instead, we compare the spec directly.
useEffect(() => {
if (spec && isDirty && spec !== lastSpecRef.current) {
lastSpecRef.current = spec;
undoRedo.recordSnapshot();
if (spec && spec !== lastSpecRef.current) {
// Deep compare to avoid recording duplicate snapshots
const specStr = JSON.stringify(spec);
const lastStr = lastSpecRef.current ? JSON.stringify(lastSpecRef.current) : '';
if (specStr !== lastStr) {
lastSpecRef.current = spec;
undoRedo.recordSnapshot();
}
}
}, [spec, isDirty, undoRedo]);
}, [spec, undoRedo]);
// Clear history when study changes
useEffect(() => {

View File

@@ -0,0 +1,288 @@
/**
* useSpecWebSocket - WebSocket connection for real-time spec sync
*
* Connects to the backend WebSocket endpoint for live spec updates.
* Handles auto-reconnection, message parsing, and store updates.
*
* P2.11-P2.14: WebSocket sync implementation
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useSpecStore } from './useSpecStore';
// ============================================================================
// Types
// ============================================================================
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
interface SpecWebSocketMessage {
type: 'modification' | 'full_sync' | 'error' | 'ping';
payload: unknown;
}
interface ModificationPayload {
operation: 'set' | 'add' | 'remove';
path: string;
value?: unknown;
modified_by: string;
timestamp: string;
hash: string;
}
interface ErrorPayload {
message: string;
code?: string;
}
interface UseSpecWebSocketOptions {
/**
* Enable auto-reconnect on disconnect (default: true)
*/
autoReconnect?: boolean;
/**
* Reconnect delay in ms (default: 3000)
*/
reconnectDelay?: number;
/**
* Max reconnect attempts (default: 10)
*/
maxReconnectAttempts?: number;
/**
* Client identifier for tracking modifications (default: 'canvas')
*/
clientId?: string;
}
interface UseSpecWebSocketReturn {
/**
* Current connection status
*/
status: ConnectionStatus;
/**
* Manually disconnect
*/
disconnect: () => void;
/**
* Manually reconnect
*/
reconnect: () => void;
/**
* Send a message to the WebSocket (for future use)
*/
send: (message: SpecWebSocketMessage) => void;
/**
* Last error message if any
*/
lastError: string | null;
}
// ============================================================================
// Hook
// ============================================================================
export function useSpecWebSocket(
studyId: string | null,
options: UseSpecWebSocketOptions = {}
): UseSpecWebSocketReturn {
const {
autoReconnect = true,
reconnectDelay = 3000,
maxReconnectAttempts = 10,
clientId = 'canvas',
} = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [lastError, setLastError] = useState<string | null>(null);
// Get store actions
const reloadSpec = useSpecStore((s) => s.reloadSpec);
const setError = useSpecStore((s) => s.setError);
// Build WebSocket URL
const getWsUrl = useCallback((id: string): string => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}/api/studies/${encodeURIComponent(id)}/spec/sync?client_id=${clientId}`;
}, [clientId]);
// Handle incoming messages
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message: SpecWebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'modification': {
const payload = message.payload as ModificationPayload;
// Skip if this is our own modification
if (payload.modified_by === clientId) {
return;
}
// Reload spec to get latest state
// In a more sophisticated implementation, we could apply the patch locally
reloadSpec().catch((err) => {
console.error('Failed to reload spec after modification:', err);
});
break;
}
case 'full_sync': {
// Full spec sync requested (e.g., after reconnect)
reloadSpec().catch((err) => {
console.error('Failed to reload spec during full_sync:', err);
});
break;
}
case 'error': {
const payload = message.payload as ErrorPayload;
console.error('WebSocket error:', payload.message);
setLastError(payload.message);
setError(payload.message);
break;
}
case 'ping': {
// Keep-alive ping, respond with pong
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'pong' }));
}
break;
}
default:
console.warn('Unknown WebSocket message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}, [clientId, reloadSpec, setError]);
// Connect to WebSocket
const connect = useCallback(() => {
if (!studyId) return;
// Clean up existing connection
if (wsRef.current) {
wsRef.current.close();
}
setStatus('connecting');
setLastError(null);
const url = getWsUrl(studyId);
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('connected');
reconnectAttemptsRef.current = 0;
};
ws.onmessage = handleMessage;
ws.onerror = (event) => {
console.error('WebSocket error:', event);
setLastError('WebSocket connection error');
};
ws.onclose = (_event) => {
setStatus('disconnected');
// Check if we should reconnect
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
setStatus('reconnecting');
// Clear any existing reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
// Schedule reconnect with exponential backoff
const delay = reconnectDelay * Math.min(reconnectAttemptsRef.current, 5);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
setLastError('Max reconnection attempts reached');
}
};
wsRef.current = ws;
}, [studyId, getWsUrl, handleMessage, autoReconnect, reconnectDelay, maxReconnectAttempts]);
// Disconnect
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
setStatus('disconnected');
}, [maxReconnectAttempts]);
// Reconnect
const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
connect();
}, [connect]);
// Send message
const send = useCallback((message: SpecWebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected, cannot send message');
}
}, []);
// Connect when studyId changes
useEffect(() => {
if (studyId) {
connect();
} else {
disconnect();
}
return () => {
// Cleanup on unmount or studyId change
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [studyId, connect, disconnect]);
return {
status,
disconnect,
reconnect,
send,
lastError,
};
}
export default useSpecWebSocket;

Some files were not shown because too many files have changed in this diff Show More