docs: Comprehensive documentation update for Dashboard V3 and Canvas
## Documentation Updates - DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection - DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE - CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features - CLAUDE.md: Added dashboard quick reference and Canvas V3 features ## Canvas V3 Features Documented - File Browser: Browse studies directory for model files - Model Introspection: Auto-discover expressions, solver type, dependencies - One-Click Add: Add expressions as design variables instantly - Claude Bug Fixes: WebSocket reconnection, SQL errors resolved - Health Check: /api/health endpoint for monitoring ## Backend Services - NX introspection service with expression discovery - File browser API with type filtering - Claude session management improvements - Context builder enhancements ## Frontend Components - FileBrowser: Modal for file selection with search - IntrospectionPanel: View discovered model information - ExpressionSelector: Dropdown for design variable configuration - Improved chat hooks with reconnection logic ## Plan Documents - Added RALPH_LOOP_CANVAS_V2/V3 implementation records - Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN - Added investigation and sync documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,10 @@ async def create_session(request: CreateSessionRequest):
|
||||
"is_alive": session.is_alive(),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
import traceback
|
||||
error_msg = f"{type(e).__name__}: {str(e) or 'No message'}"
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=error_msg)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
@@ -146,8 +149,9 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
WebSocket for real-time chat with a session.
|
||||
|
||||
Message formats (client -> server):
|
||||
{"type": "message", "content": "user message"}
|
||||
{"type": "message", "content": "user message", "canvas_state": {...}}
|
||||
{"type": "set_study", "study_id": "study_name"}
|
||||
{"type": "set_canvas", "canvas_state": {...}}
|
||||
{"type": "ping"}
|
||||
|
||||
Message formats (server -> client):
|
||||
@@ -158,6 +162,7 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
{"type": "error", "message": "..."}
|
||||
{"type": "pong"}
|
||||
{"type": "context_updated", "study_id": "..."}
|
||||
{"type": "canvas_updated", "canvas_state": {...}}
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
@@ -169,6 +174,9 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Track current canvas state for this connection
|
||||
current_canvas_state: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
@@ -178,7 +186,14 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
if not content:
|
||||
continue
|
||||
|
||||
async for chunk in manager.send_message(session_id, content):
|
||||
# Get canvas state from message or use stored state
|
||||
canvas_state = data.get("canvas_state") or current_canvas_state
|
||||
|
||||
async for chunk in manager.send_message(
|
||||
session_id,
|
||||
content,
|
||||
canvas_state=canvas_state if canvas_state else None,
|
||||
):
|
||||
await websocket.send_json(chunk)
|
||||
|
||||
elif data.get("type") == "set_study":
|
||||
@@ -190,6 +205,14 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
"study_id": study_id,
|
||||
})
|
||||
|
||||
elif data.get("type") == "set_canvas":
|
||||
# Update canvas state for this connection
|
||||
current_canvas_state = data.get("canvas_state", {})
|
||||
await websocket.send_json({
|
||||
"type": "canvas_updated",
|
||||
"canvas_state": current_canvas_state,
|
||||
})
|
||||
|
||||
elif data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
"""
|
||||
Files API Routes
|
||||
|
||||
Provides file browsing capabilities for the Canvas Builder.
|
||||
Provides file browsing and import capabilities for the Canvas Builder.
|
||||
Supports importing NX model files from anywhere on the file system.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, UploadFile, File, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
"""Request to import a file from a Windows path"""
|
||||
source_path: str
|
||||
study_name: str
|
||||
copy_related: bool = True
|
||||
|
||||
# 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(
|
||||
@@ -153,3 +165,240 @@ async def check_file_exists(path: str):
|
||||
result["name"] = file_path.name
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def find_related_nx_files(source_path: Path) -> List[Path]:
|
||||
"""
|
||||
Find all related NX files based on naming conventions.
|
||||
|
||||
Given a .sim file like 'model_sim1.sim', finds:
|
||||
- model.prt (geometry part)
|
||||
- model_fem1.fem (FEM file)
|
||||
- model_fem1_i.prt (idealized part)
|
||||
- model_sim1.sim (simulation)
|
||||
|
||||
Args:
|
||||
source_path: Path to any NX file
|
||||
|
||||
Returns:
|
||||
List of all related file paths that exist
|
||||
"""
|
||||
related = []
|
||||
parent = source_path.parent
|
||||
stem = source_path.stem
|
||||
suffix = source_path.suffix.lower()
|
||||
|
||||
# Extract base name by removing _sim1, _fem1, _i suffixes
|
||||
base_name = stem
|
||||
base_name = re.sub(r'_sim\d*$', '', base_name)
|
||||
base_name = re.sub(r'_fem\d*$', '', base_name)
|
||||
base_name = re.sub(r'_i$', '', base_name)
|
||||
|
||||
# Define patterns to search for
|
||||
patterns = [
|
||||
f"{base_name}.prt", # Main geometry
|
||||
f"{base_name}_i.prt", # Idealized part
|
||||
f"{base_name}_fem*.fem", # FEM files
|
||||
f"{base_name}_fem*_i.prt", # Idealized FEM parts
|
||||
f"{base_name}_sim*.sim", # Simulation files
|
||||
f"{base_name}.afem", # Assembled FEM
|
||||
]
|
||||
|
||||
# Search for matching files
|
||||
for pattern in patterns:
|
||||
for match in parent.glob(pattern):
|
||||
if match.exists() and match not in related:
|
||||
related.append(match)
|
||||
|
||||
# Also include the source file itself
|
||||
if source_path.exists() and source_path not in related:
|
||||
related.append(source_path)
|
||||
|
||||
return related
|
||||
|
||||
|
||||
@router.get("/validate-path")
|
||||
async def validate_external_path(path: str):
|
||||
"""
|
||||
Validate an external Windows path and return info about related files.
|
||||
|
||||
Args:
|
||||
path: Absolute Windows path (e.g., C:\\Models\\bracket.sim)
|
||||
|
||||
Returns:
|
||||
Information about the file and related files
|
||||
"""
|
||||
try:
|
||||
source_path = Path(path)
|
||||
|
||||
if not source_path.exists():
|
||||
return {
|
||||
"valid": False,
|
||||
"error": f"Path does not exist: {path}",
|
||||
}
|
||||
|
||||
if not source_path.is_file():
|
||||
return {
|
||||
"valid": False,
|
||||
"error": "Path is not a file",
|
||||
}
|
||||
|
||||
# Check if it's a valid NX file type
|
||||
valid_extensions = ['.prt', '.sim', '.fem', '.afem']
|
||||
if source_path.suffix.lower() not in valid_extensions:
|
||||
return {
|
||||
"valid": False,
|
||||
"error": f"Invalid file type. Expected: {', '.join(valid_extensions)}",
|
||||
}
|
||||
|
||||
# Find related files
|
||||
related = find_related_nx_files(source_path)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"path": str(source_path),
|
||||
"name": source_path.name,
|
||||
"size": source_path.stat().st_size,
|
||||
"related_files": [
|
||||
{
|
||||
"name": f.name,
|
||||
"path": str(f),
|
||||
"size": f.stat().st_size,
|
||||
"type": f.suffix.lower(),
|
||||
}
|
||||
for f in related
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-from-path")
|
||||
async def import_from_path(request: ImportRequest):
|
||||
"""
|
||||
Import NX model files from an external path into a study folder.
|
||||
|
||||
This will:
|
||||
1. Create the study folder if it doesn't exist
|
||||
2. Copy the specified file
|
||||
3. Optionally copy all related files (.prt, .sim, .fem, _i.prt)
|
||||
|
||||
Args:
|
||||
request: ImportRequest with source_path, study_name, and copy_related flag
|
||||
|
||||
Returns:
|
||||
List of imported files
|
||||
"""
|
||||
try:
|
||||
source_path = Path(request.source_path)
|
||||
|
||||
if not source_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}")
|
||||
|
||||
# Create study folder structure
|
||||
study_dir = STUDIES_ROOT / request.study_name
|
||||
model_dir = study_dir / "1_model"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find files to copy
|
||||
if request.copy_related:
|
||||
files_to_copy = find_related_nx_files(source_path)
|
||||
else:
|
||||
files_to_copy = [source_path]
|
||||
|
||||
imported = []
|
||||
for src_file in files_to_copy:
|
||||
dest_file = model_dir / src_file.name
|
||||
|
||||
# Skip if already exists (avoid overwrite)
|
||||
if dest_file.exists():
|
||||
imported.append({
|
||||
"name": src_file.name,
|
||||
"status": "skipped",
|
||||
"reason": "Already exists",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
})
|
||||
continue
|
||||
|
||||
# Copy file
|
||||
shutil.copy2(src_file, dest_file)
|
||||
imported.append({
|
||||
"name": src_file.name,
|
||||
"status": "imported",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": dest_file.stat().st_size,
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"study_name": request.study_name,
|
||||
"imported_files": imported,
|
||||
"total_imported": len([f for f in imported if f["status"] == "imported"]),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_files(
|
||||
files: List[UploadFile] = File(...),
|
||||
study_name: str = Query(...),
|
||||
):
|
||||
"""
|
||||
Upload NX model files to a study folder.
|
||||
|
||||
Args:
|
||||
files: List of files to upload
|
||||
study_name: Target study name
|
||||
|
||||
Returns:
|
||||
List of uploaded files
|
||||
"""
|
||||
try:
|
||||
# Create study folder structure
|
||||
study_dir = STUDIES_ROOT / study_name
|
||||
model_dir = study_dir / "1_model"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
uploaded = []
|
||||
for file in files:
|
||||
# Validate file type
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in ['.prt', '.sim', '.fem', '.afem']:
|
||||
uploaded.append({
|
||||
"name": file.filename,
|
||||
"status": "rejected",
|
||||
"reason": f"Invalid file type: {suffix}",
|
||||
})
|
||||
continue
|
||||
|
||||
dest_file = model_dir / file.filename
|
||||
|
||||
# Save file
|
||||
content = await file.read()
|
||||
with open(dest_file, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
uploaded.append({
|
||||
"name": file.filename,
|
||||
"status": "uploaded",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": len(content),
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"study_name": study_name,
|
||||
"uploaded_files": uploaded,
|
||||
"total_uploaded": len([f for f in uploaded if f["status"] == "uploaded"]),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user