feat(canvas): Add file browser, introspection, and improve node flow

Phase 1-7 of Canvas V4 Ralph Loop implementation:

Backend:
- Add /api/files routes for browsing model files
- Add /api/nx routes for NX model introspection
- Add NXIntrospector service to discover expressions and extractors
- Add health check with database status

Frontend:
- Add FileBrowser component for selecting .sim/.prt/.fem files
- Add IntrospectionPanel to discover expressions and extractors
- Update NodeConfigPanel with browse and introspect buttons
- Update schema with NODE_HANDLES for proper flow direction
- Update validation for correct DesignVar -> Model -> Solver flow
- Update useCanvasStore.addNode() to accept custom data

Flow correction: Design Variables now connect TO Model (as source),
not FROM Model. This matches the actual data flow in optimization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 14:47:10 -05:00
parent 62284a995e
commit 1c7c7aff05
13 changed files with 4401 additions and 25 deletions

View File

@@ -0,0 +1,155 @@
"""
Files API Routes
Provides file browsing capabilities for the Canvas Builder.
"""
from fastapi import APIRouter, Query
from pathlib import Path
from typing import List, Optional
import os
router = APIRouter()
# Path to studies root (go up 5 levels from this file)
_file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(_file_path))
)))))
STUDIES_ROOT = ATOMIZER_ROOT / "studies"
@router.get("/list")
async def list_files(
path: str = "",
types: str = ".sim,.prt,.fem,.afem"
):
"""
List files in a directory, filtered by type.
Args:
path: Relative path from studies root (empty for root)
types: Comma-separated list of file extensions to include
Returns:
List of files and directories with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
if not base_path.exists():
return {"files": [], "path": path, "error": "Directory not found"}
files = []
try:
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
# Skip hidden files and directories
if entry.name.startswith('.'):
continue
if entry.is_dir():
# Include directories
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": True,
})
else:
# Include files matching type filter
suffix = entry.suffix.lower()
if suffix in allowed_types:
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
except PermissionError:
return {"files": [], "path": path, "error": "Permission denied"}
except Exception as e:
return {"files": [], "path": path, "error": str(e)}
return {"files": files, "path": path}
@router.get("/search")
async def search_files(
query: str,
types: str = ".sim,.prt,.fem,.afem",
max_results: int = 50
):
"""
Search for files by name pattern.
Args:
query: Search pattern (partial name match)
types: Comma-separated list of file extensions to include
max_results: Maximum number of results to return
Returns:
List of matching files with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
query_lower = query.lower()
files = []
def search_recursive(directory: Path, depth: int = 0):
"""Recursively search for matching files"""
if depth > 10 or len(files) >= max_results: # Limit depth and results
return
try:
for entry in directory.iterdir():
if len(files) >= max_results:
return
if entry.name.startswith('.'):
continue
if entry.is_dir():
search_recursive(entry, depth + 1)
elif entry.suffix.lower() in allowed_types:
if query_lower in entry.name.lower():
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
except (PermissionError, OSError):
pass
search_recursive(STUDIES_ROOT)
return {"files": files, "query": query, "total": len(files)}
@router.get("/exists")
async def check_file_exists(path: str):
"""
Check if a file exists.
Args:
path: Relative path from studies root
Returns:
Boolean indicating if file exists and file info
"""
file_path = STUDIES_ROOT / path
exists = file_path.exists()
result = {
"exists": exists,
"path": path,
}
if exists:
result["isDirectory"] = file_path.is_dir()
if file_path.is_file():
result["size"] = file_path.stat().st_size
result["name"] = file_path.name
return result