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>
1950 lines
60 KiB
Markdown
1950 lines
60 KiB
Markdown
# Ralph Loop: Canvas V4 - Model Introspection & Claude Integration
|
|
|
|
**Purpose:** Fix critical Canvas bugs and implement NX model introspection with Claude control
|
|
**Execution:** Autonomous, all phases sequential, no stopping
|
|
**Estimated Duration:** 12 work units
|
|
**Priority:** HIGH - Core functionality broken
|
|
|
|
---
|
|
|
|
## Launch Command
|
|
|
|
```powershell
|
|
cd C:\Users\antoi\Atomizer
|
|
claude --dangerously-skip-permissions
|
|
```
|
|
|
|
Paste everything below the line.
|
|
|
|
---
|
|
|
|
You are executing a **multi-phase autonomous development session** to fix critical Canvas Builder issues and implement NX model introspection.
|
|
|
|
## Critical Issues to Fix
|
|
|
|
| Issue | Impact | Priority |
|
|
|-------|--------|----------|
|
|
| **Claude bot SQL error on Validate** | Blocking - can't validate | P0 |
|
|
| **Execute with Claude broken** | Blocking - can't create studies | P0 |
|
|
| **Expressions not connected to Model** | UX - wrong data flow | P1 |
|
|
| **No file browser for .sim** | UX - can't find files | P1 |
|
|
| **No model introspection** | Feature - manual config only | P2 |
|
|
|
|
## Key Learnings to Implement
|
|
|
|
1. **Data Flow**: Expressions → Model (not Model → Expressions)
|
|
2. **Model has multiple inputs**: One per design variable
|
|
3. **Introspection discovers**: Expressions, solver type, dependent files (.fem, .prt)
|
|
4. **Auto-filter**: Selecting .sim shows only related elements
|
|
|
|
## Environment
|
|
|
|
```
|
|
Working Directory: C:/Users/antoi/Atomizer
|
|
Frontend: atomizer-dashboard/frontend/
|
|
Backend: atomizer-dashboard/backend/
|
|
Python: C:/Users/antoi/anaconda3/envs/atomizer/python.exe
|
|
Git: Push to origin AND github
|
|
```
|
|
|
|
## Execution Rules
|
|
|
|
1. **TodoWrite** - Track every task, mark complete immediately
|
|
2. **Sequential phases** - Complete each phase fully before next
|
|
3. **Test after each phase** - Run `npm run build` to verify
|
|
4. **Read before edit** - ALWAYS read files before modifying
|
|
5. **No guessing** - If unsure, read more code first
|
|
6. **Commit per phase** - Git commit after each major phase
|
|
|
|
---
|
|
|
|
# PHASE 1: Fix Claude Bot SQL Error (CRITICAL)
|
|
|
|
## T1.1 - Investigate SQL Error
|
|
|
|
First, find the source of the SQL error:
|
|
|
|
```
|
|
Read: atomizer-dashboard/backend/api/services/conversation_store.py
|
|
Read: atomizer-dashboard/backend/api/services/session_manager.py
|
|
Read: atomizer-dashboard/backend/api/routes/terminal.py
|
|
```
|
|
|
|
**Common SQL issues:**
|
|
- Table not created before use
|
|
- Column mismatch
|
|
- Connection not initialized
|
|
- Missing migrations
|
|
|
|
**Look for:**
|
|
- `CREATE TABLE IF NOT EXISTS` - ensure tables exist
|
|
- Try/catch around all DB operations
|
|
- Connection initialization on startup
|
|
|
|
## T1.2 - Fix Database Initialization
|
|
|
|
**File:** `atomizer-dashboard/backend/api/services/conversation_store.py`
|
|
|
|
Ensure robust initialization:
|
|
|
|
```python
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from contextlib import contextmanager
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class ConversationStore:
|
|
def __init__(self, db_path: str = "sessions.db"):
|
|
self.db_path = Path(db_path)
|
|
self._init_db()
|
|
|
|
def _init_db(self):
|
|
"""Initialize database with all required tables."""
|
|
try:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Sessions table
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
study_path TEXT,
|
|
context TEXT
|
|
)
|
|
''')
|
|
|
|
# Messages table
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
)
|
|
''')
|
|
|
|
conn.commit()
|
|
logger.info(f"Database initialized: {self.db_path}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize database: {e}")
|
|
raise
|
|
|
|
@contextmanager
|
|
def _get_connection(self):
|
|
"""Get a database connection with proper error handling."""
|
|
conn = None
|
|
try:
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
yield conn
|
|
except sqlite3.Error as e:
|
|
logger.error(f"Database error: {e}")
|
|
if conn:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
def get_or_create_session(self, session_id: str, study_path: str = None):
|
|
"""Get existing session or create new one."""
|
|
try:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# Check if exists
|
|
cursor.execute('SELECT * FROM sessions WHERE id = ?', (session_id,))
|
|
row = cursor.fetchone()
|
|
|
|
if row:
|
|
return dict(row)
|
|
|
|
# Create new
|
|
cursor.execute(
|
|
'INSERT INTO sessions (id, study_path) VALUES (?, ?)',
|
|
(session_id, study_path)
|
|
)
|
|
conn.commit()
|
|
|
|
return {
|
|
'id': session_id,
|
|
'study_path': study_path,
|
|
'created_at': None
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get/create session: {e}")
|
|
return {'id': session_id, 'study_path': study_path}
|
|
|
|
def add_message(self, session_id: str, role: str, content: str):
|
|
"""Add a message to session history."""
|
|
try:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)',
|
|
(session_id, role, content)
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
logger.error(f"Failed to add message: {e}")
|
|
# Don't raise - message logging is not critical
|
|
|
|
def get_messages(self, session_id: str, limit: int = 50):
|
|
"""Get recent messages for a session."""
|
|
try:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
'''SELECT role, content, created_at
|
|
FROM messages
|
|
WHERE session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?''',
|
|
(session_id, limit)
|
|
)
|
|
rows = cursor.fetchall()
|
|
return [dict(row) for row in reversed(rows)]
|
|
except Exception as e:
|
|
logger.error(f"Failed to get messages: {e}")
|
|
return []
|
|
```
|
|
|
|
## T1.3 - Fix Session Manager
|
|
|
|
**File:** `atomizer-dashboard/backend/api/services/session_manager.py`
|
|
|
|
Add error handling:
|
|
|
|
```python
|
|
class SessionManager:
|
|
def __init__(self):
|
|
self.store = ConversationStore()
|
|
self.active_sessions = {}
|
|
|
|
async def get_session(self, session_id: str):
|
|
"""Get or create a session with error handling."""
|
|
try:
|
|
if session_id not in self.active_sessions:
|
|
session_data = self.store.get_or_create_session(session_id)
|
|
self.active_sessions[session_id] = session_data
|
|
return self.active_sessions.get(session_id)
|
|
except Exception as e:
|
|
logger.error(f"Session error: {e}")
|
|
# Return minimal session on error
|
|
return {'id': session_id, 'study_path': None}
|
|
```
|
|
|
|
## T1.4 - Add Health Check Endpoint
|
|
|
|
**File:** `atomizer-dashboard/backend/api/main.py`
|
|
|
|
```python
|
|
@app.get("/api/health")
|
|
async def health_check():
|
|
"""Health check with database status."""
|
|
try:
|
|
from api.services.conversation_store import ConversationStore
|
|
store = ConversationStore()
|
|
store.get_or_create_session("health_check")
|
|
return {"status": "healthy", "database": "connected"}
|
|
except Exception as e:
|
|
return {"status": "unhealthy", "database": str(e)}
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 2: Fix Execute with Claude (CRITICAL)
|
|
|
|
## T2.1 - Investigate Chat Integration
|
|
|
|
Read the chat components:
|
|
|
|
```
|
|
Read: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts
|
|
Read: atomizer-dashboard/frontend/src/hooks/useChat.ts
|
|
Read: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx
|
|
Read: atomizer-dashboard/backend/api/routes/terminal.py
|
|
```
|
|
|
|
## T2.2 - Fix WebSocket Chat Hook
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/hooks/useChat.ts`
|
|
|
|
Ensure proper connection handling:
|
|
|
|
```typescript
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
|
|
interface ChatMessage {
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
timestamp?: Date;
|
|
}
|
|
|
|
interface UseChatOptions {
|
|
endpoint?: string;
|
|
sessionId?: string;
|
|
onError?: (error: string) => void;
|
|
}
|
|
|
|
export function useChat(options: UseChatOptions = {}) {
|
|
const {
|
|
endpoint = '/api/chat',
|
|
sessionId = 'default',
|
|
onError,
|
|
} = options;
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [isThinking, setIsThinking] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const reconnectAttempts = useRef(0);
|
|
const maxReconnectAttempts = 5;
|
|
|
|
const connect = useCallback(() => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}${endpoint}?session_id=${sessionId}`;
|
|
|
|
console.log('Connecting to WebSocket:', wsUrl);
|
|
wsRef.current = new WebSocket(wsUrl);
|
|
|
|
wsRef.current.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
setIsConnected(true);
|
|
setError(null);
|
|
reconnectAttempts.current = 0;
|
|
};
|
|
|
|
wsRef.current.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
if (data.type === 'message') {
|
|
setMessages((prev) => [...prev, {
|
|
role: data.role || 'assistant',
|
|
content: data.content,
|
|
timestamp: new Date(),
|
|
}]);
|
|
setIsThinking(false);
|
|
} else if (data.type === 'thinking') {
|
|
setIsThinking(true);
|
|
} else if (data.type === 'error') {
|
|
setError(data.message);
|
|
setIsThinking(false);
|
|
onError?.(data.message);
|
|
} else if (data.type === 'done') {
|
|
setIsThinking(false);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse message:', e);
|
|
}
|
|
};
|
|
|
|
wsRef.current.onclose = (event) => {
|
|
console.log('WebSocket closed:', event.code, event.reason);
|
|
setIsConnected(false);
|
|
|
|
// Attempt reconnect
|
|
if (reconnectAttempts.current < maxReconnectAttempts) {
|
|
reconnectAttempts.current++;
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
|
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
|
|
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
connect();
|
|
}, delay);
|
|
} else {
|
|
setError('Connection lost. Please refresh the page.');
|
|
}
|
|
};
|
|
|
|
wsRef.current.onerror = (event) => {
|
|
console.error('WebSocket error:', event);
|
|
setError('Connection error');
|
|
};
|
|
} catch (e) {
|
|
console.error('Failed to create WebSocket:', e);
|
|
setError('Failed to connect');
|
|
}
|
|
}, [endpoint, sessionId, onError]);
|
|
|
|
const disconnect = useCallback(() => {
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
setIsConnected(false);
|
|
}, []);
|
|
|
|
const sendMessage = useCallback(async (content: string) => {
|
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
setError('Not connected');
|
|
return;
|
|
}
|
|
|
|
// Add user message immediately
|
|
setMessages((prev) => [...prev, {
|
|
role: 'user',
|
|
content,
|
|
timestamp: new Date(),
|
|
}]);
|
|
|
|
setIsThinking(true);
|
|
setError(null);
|
|
|
|
try {
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'message',
|
|
content,
|
|
session_id: sessionId,
|
|
}));
|
|
} catch (e) {
|
|
console.error('Failed to send message:', e);
|
|
setError('Failed to send message');
|
|
setIsThinking(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const clearMessages = useCallback(() => {
|
|
setMessages([]);
|
|
}, []);
|
|
|
|
const reconnect = useCallback(() => {
|
|
disconnect();
|
|
reconnectAttempts.current = 0;
|
|
setTimeout(connect, 100);
|
|
}, [connect, disconnect]);
|
|
|
|
// Auto-connect on mount
|
|
useEffect(() => {
|
|
connect();
|
|
return () => disconnect();
|
|
}, [connect, disconnect]);
|
|
|
|
return {
|
|
messages,
|
|
isConnected,
|
|
isThinking,
|
|
error,
|
|
sendMessage,
|
|
clearMessages,
|
|
reconnect,
|
|
connect,
|
|
disconnect,
|
|
};
|
|
}
|
|
```
|
|
|
|
## T2.3 - Fix Canvas Chat Hook
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts`
|
|
|
|
```typescript
|
|
import { useCallback } from 'react';
|
|
import { useChat } from './useChat';
|
|
import { useCanvasStore } from './useCanvasStore';
|
|
import { toOptimizationIntent } from '../lib/canvas/intent';
|
|
|
|
export function useCanvasChat() {
|
|
const { nodes, edges, validation } = useCanvasStore();
|
|
|
|
const chat = useChat({
|
|
endpoint: '/api/chat/canvas',
|
|
sessionId: `canvas_${Date.now()}`,
|
|
onError: (error) => {
|
|
console.error('Canvas chat error:', error);
|
|
},
|
|
});
|
|
|
|
const validateWithClaude = useCallback(async () => {
|
|
try {
|
|
const intent = toOptimizationIntent(nodes, edges);
|
|
const message = `Please validate this Canvas workflow configuration:
|
|
|
|
\`\`\`json
|
|
${JSON.stringify(intent, null, 2)}
|
|
\`\`\`
|
|
|
|
Check for:
|
|
1. Missing required nodes (Model, Solver, at least one Objective, Algorithm)
|
|
2. Invalid connections
|
|
3. Configuration issues
|
|
4. Suggest improvements`;
|
|
|
|
await chat.sendMessage(message);
|
|
} catch (e) {
|
|
console.error('Validation error:', e);
|
|
}
|
|
}, [nodes, edges, chat]);
|
|
|
|
const processWithClaude = useCallback(async (studyName?: string, options?: {
|
|
overwrite?: boolean;
|
|
copyModels?: boolean;
|
|
}) => {
|
|
try {
|
|
const intent = toOptimizationIntent(nodes, edges);
|
|
const message = `Create an optimization study from this Canvas workflow:
|
|
|
|
Study Name: ${studyName || 'new_study'}
|
|
Options: ${JSON.stringify(options || {})}
|
|
|
|
\`\`\`json
|
|
${JSON.stringify(intent, null, 2)}
|
|
\`\`\`
|
|
|
|
Please:
|
|
1. Validate the configuration
|
|
2. Generate optimization_config.json
|
|
3. Generate run_optimization.py using Atomizer protocols
|
|
4. Create the study folder structure`;
|
|
|
|
await chat.sendMessage(message);
|
|
} catch (e) {
|
|
console.error('Process error:', e);
|
|
}
|
|
}, [nodes, edges, chat]);
|
|
|
|
const askClaude = useCallback(async (question: string) => {
|
|
try {
|
|
const intent = toOptimizationIntent(nodes, edges);
|
|
const message = `Context - Current Canvas workflow:
|
|
\`\`\`json
|
|
${JSON.stringify(intent, null, 2)}
|
|
\`\`\`
|
|
|
|
Question: ${question}`;
|
|
|
|
await chat.sendMessage(message);
|
|
} catch (e) {
|
|
console.error('Ask error:', e);
|
|
}
|
|
}, [nodes, edges, chat]);
|
|
|
|
return {
|
|
...chat,
|
|
validateWithClaude,
|
|
processWithClaude,
|
|
askClaude,
|
|
};
|
|
}
|
|
```
|
|
|
|
## T2.4 - Fix Backend Chat WebSocket
|
|
|
|
**File:** `atomizer-dashboard/backend/api/routes/terminal.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
from api.services.session_manager import SessionManager
|
|
from api.services.claude_agent import ClaudeAgent
|
|
import json
|
|
import logging
|
|
import asyncio
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
session_manager = SessionManager()
|
|
|
|
@router.websocket("/api/chat")
|
|
@router.websocket("/api/chat/canvas")
|
|
async def chat_websocket(websocket: WebSocket):
|
|
"""WebSocket endpoint for Claude chat."""
|
|
await websocket.accept()
|
|
|
|
session_id = websocket.query_params.get('session_id', 'default')
|
|
logger.info(f"Chat WebSocket connected: {session_id}")
|
|
|
|
try:
|
|
# Initialize session
|
|
session = await session_manager.get_session(session_id)
|
|
|
|
# Send connection confirmation
|
|
await websocket.send_json({
|
|
"type": "connected",
|
|
"session_id": session_id,
|
|
})
|
|
|
|
while True:
|
|
try:
|
|
# Receive message
|
|
data = await websocket.receive_json()
|
|
|
|
if data.get('type') == 'message':
|
|
content = data.get('content', '')
|
|
|
|
# Send thinking indicator
|
|
await websocket.send_json({"type": "thinking"})
|
|
|
|
try:
|
|
# Process with Claude
|
|
agent = ClaudeAgent()
|
|
response = await agent.process_message(
|
|
session_id=session_id,
|
|
message=content,
|
|
context=session.get('context')
|
|
)
|
|
|
|
# Send response
|
|
await websocket.send_json({
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": response,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Claude error: {e}")
|
|
await websocket.send_json({
|
|
"type": "error",
|
|
"message": str(e),
|
|
})
|
|
|
|
# Send done
|
|
await websocket.send_json({"type": "done"})
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Invalid JSON: {e}")
|
|
await websocket.send_json({
|
|
"type": "error",
|
|
"message": "Invalid message format",
|
|
})
|
|
|
|
except WebSocketDisconnect:
|
|
logger.info(f"Chat WebSocket disconnected: {session_id}")
|
|
except Exception as e:
|
|
logger.error(f"WebSocket error: {e}")
|
|
try:
|
|
await websocket.send_json({
|
|
"type": "error",
|
|
"message": str(e),
|
|
})
|
|
except:
|
|
pass
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 3: Fix Node Connection Flow (Expressions → Model)
|
|
|
|
## T3.1 - Update Node Schema for Inputs
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/lib/canvas/schema.ts`
|
|
|
|
Add input/output handle definitions:
|
|
|
|
```typescript
|
|
// Node handle positions
|
|
export interface NodeHandles {
|
|
inputs: HandleConfig[];
|
|
outputs: HandleConfig[];
|
|
}
|
|
|
|
export interface HandleConfig {
|
|
id: string;
|
|
type: 'source' | 'target';
|
|
position: 'top' | 'bottom' | 'left' | 'right';
|
|
label?: string;
|
|
}
|
|
|
|
// Define handles for each node type
|
|
export const NODE_HANDLES: Record<NodeType, NodeHandles> = {
|
|
model: {
|
|
inputs: [
|
|
{ id: 'params', type: 'target', position: 'left', label: 'Design Vars' },
|
|
],
|
|
outputs: [
|
|
{ id: 'sim', type: 'source', position: 'right', label: 'Simulation' },
|
|
],
|
|
},
|
|
solver: {
|
|
inputs: [
|
|
{ id: 'model', type: 'target', position: 'left', label: 'Model' },
|
|
],
|
|
outputs: [
|
|
{ id: 'results', type: 'source', position: 'right', label: 'Results' },
|
|
],
|
|
},
|
|
designVar: {
|
|
inputs: [],
|
|
outputs: [
|
|
{ id: 'value', type: 'source', position: 'right', label: 'Value' },
|
|
],
|
|
},
|
|
extractor: {
|
|
inputs: [
|
|
{ id: 'results', type: 'target', position: 'left', label: 'Results' },
|
|
],
|
|
outputs: [
|
|
{ id: 'value', type: 'source', position: 'right', label: 'Value' },
|
|
],
|
|
},
|
|
objective: {
|
|
inputs: [
|
|
{ id: 'value', type: 'target', position: 'left', label: 'Value' },
|
|
],
|
|
outputs: [
|
|
{ id: 'objective', type: 'source', position: 'right', label: 'To Algo' },
|
|
],
|
|
},
|
|
constraint: {
|
|
inputs: [
|
|
{ id: 'value', type: 'target', position: 'left', label: 'Value' },
|
|
],
|
|
outputs: [],
|
|
},
|
|
algorithm: {
|
|
inputs: [
|
|
{ id: 'objectives', type: 'target', position: 'left', label: 'Objectives' },
|
|
],
|
|
outputs: [],
|
|
},
|
|
surrogate: {
|
|
inputs: [
|
|
{ id: 'algo', type: 'target', position: 'left', label: 'Algorithm' },
|
|
],
|
|
outputs: [],
|
|
},
|
|
};
|
|
```
|
|
|
|
## T3.2 - Update BaseNode with Multiple Handles
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/components/canvas/nodes/BaseNode.tsx`
|
|
|
|
```typescript
|
|
import { Handle, Position } from 'reactflow';
|
|
import { NODE_HANDLES } from '../../../lib/canvas/schema';
|
|
|
|
interface BaseNodeProps {
|
|
type: NodeType;
|
|
data: CanvasNodeData;
|
|
selected: boolean;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
export function BaseNode({ type, data, selected, children }: BaseNodeProps) {
|
|
const handles = NODE_HANDLES[type];
|
|
const icon = getIconForType(type);
|
|
const iconColor = getColorForType(type);
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
relative px-3 py-2.5 rounded-lg border min-w-[180px] max-w-[220px]
|
|
bg-dark-850 shadow-lg transition-all duration-150
|
|
${selected ? 'border-primary-400 ring-2 ring-primary-400/30' : 'border-dark-600'}
|
|
${!data.configured ? 'border-dashed border-dark-500' : ''}
|
|
`}
|
|
>
|
|
{/* Input handles (left side) */}
|
|
{handles.inputs.map((handle, idx) => (
|
|
<Handle
|
|
key={`in-${handle.id}`}
|
|
type="target"
|
|
position={Position.Left}
|
|
id={handle.id}
|
|
style={{ top: `${30 + idx * 20}%` }}
|
|
className="!w-3 !h-3 !bg-dark-600 !border-2 !border-dark-400 hover:!bg-primary-500 hover:!border-primary-400"
|
|
/>
|
|
))}
|
|
|
|
{/* Content */}
|
|
<div className="flex items-center gap-2.5">
|
|
<div className={`${iconColor} flex-shrink-0`}>
|
|
{icon}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-white text-sm leading-tight truncate">
|
|
{data.label}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{children && (
|
|
<div className="mt-2 text-xs text-dark-300 truncate">
|
|
{children}
|
|
</div>
|
|
)}
|
|
|
|
{/* Output handles (right side) */}
|
|
{handles.outputs.map((handle, idx) => (
|
|
<Handle
|
|
key={`out-${handle.id}`}
|
|
type="source"
|
|
position={Position.Right}
|
|
id={handle.id}
|
|
style={{ top: `${30 + idx * 20}%` }}
|
|
className="!w-3 !h-3 !bg-dark-600 !border-2 !border-dark-400 hover:!bg-primary-500 hover:!border-primary-400"
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## T3.3 - Update Validation for New Flow
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/lib/canvas/validation.ts`
|
|
|
|
Update validation to check new connection flow:
|
|
|
|
```typescript
|
|
export function validateCanvas(nodes: Node[], edges: Edge[]): ValidationResult {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
// Get nodes by type
|
|
const modelNodes = nodes.filter(n => n.data.type === 'model');
|
|
const solverNodes = nodes.filter(n => n.data.type === 'solver');
|
|
const dvarNodes = nodes.filter(n => n.data.type === 'designVar');
|
|
const extractorNodes = nodes.filter(n => n.data.type === 'extractor');
|
|
const objectiveNodes = nodes.filter(n => n.data.type === 'objective');
|
|
const algoNodes = nodes.filter(n => n.data.type === 'algorithm');
|
|
|
|
// Required nodes
|
|
if (modelNodes.length === 0) errors.push('Missing Model node');
|
|
if (solverNodes.length === 0) errors.push('Missing Solver node');
|
|
if (objectiveNodes.length === 0) errors.push('Missing Objective node');
|
|
if (algoNodes.length === 0) errors.push('Missing Algorithm node');
|
|
|
|
// Check connections
|
|
// Design Variables should connect TO Model
|
|
dvarNodes.forEach(dvar => {
|
|
const hasConnection = edges.some(e =>
|
|
e.source === dvar.id &&
|
|
modelNodes.some(m => m.id === e.target)
|
|
);
|
|
if (!hasConnection) {
|
|
warnings.push(`Design variable "${dvar.data.label}" not connected to Model`);
|
|
}
|
|
});
|
|
|
|
// Model should connect to Solver
|
|
modelNodes.forEach(model => {
|
|
const hasConnection = edges.some(e =>
|
|
e.source === model.id &&
|
|
solverNodes.some(s => s.id === e.target)
|
|
);
|
|
if (!hasConnection) {
|
|
errors.push(`Model "${model.data.label}" not connected to Solver`);
|
|
}
|
|
});
|
|
|
|
// Solver should connect to Extractors
|
|
solverNodes.forEach(solver => {
|
|
const connectedExtractors = edges.filter(e =>
|
|
e.source === solver.id &&
|
|
extractorNodes.some(ex => ex.id === e.target)
|
|
);
|
|
if (connectedExtractors.length === 0) {
|
|
warnings.push('Solver not connected to any Extractor');
|
|
}
|
|
});
|
|
|
|
// Extractors should connect to Objectives
|
|
objectiveNodes.forEach(obj => {
|
|
const hasConnection = edges.some(e =>
|
|
extractorNodes.some(ex => ex.id === e.source) &&
|
|
e.target === obj.id
|
|
);
|
|
if (!hasConnection) {
|
|
errors.push(`Objective "${obj.data.label}" not connected to any Extractor`);
|
|
}
|
|
});
|
|
|
|
// Objectives should connect to Algorithm
|
|
const algoNode = algoNodes[0];
|
|
if (algoNode) {
|
|
objectiveNodes.forEach(obj => {
|
|
const hasConnection = edges.some(e =>
|
|
e.source === obj.id && e.target === algoNode.id
|
|
);
|
|
if (!hasConnection) {
|
|
warnings.push(`Objective "${obj.data.label}" not connected to Algorithm`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check configuration
|
|
nodes.forEach(node => {
|
|
if (!node.data.configured) {
|
|
warnings.push(`"${node.data.label}" is not fully configured`);
|
|
}
|
|
});
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 4: Add File Browser for Model Selection
|
|
|
|
## T4.1 - Create File Browser Component
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx`
|
|
|
|
```typescript
|
|
import { useState, useEffect } from 'react';
|
|
import { X, Folder, FileBox, ChevronRight, ChevronDown, Search, RefreshCw } from 'lucide-react';
|
|
|
|
interface FileBrowserProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelect: (filePath: string) => void;
|
|
fileTypes?: string[]; // e.g., ['.sim', '.prt', '.fem', '.afem']
|
|
initialPath?: string;
|
|
}
|
|
|
|
interface FileEntry {
|
|
name: string;
|
|
path: string;
|
|
isDirectory: boolean;
|
|
children?: FileEntry[];
|
|
}
|
|
|
|
export function FileBrowser({ isOpen, onClose, onSelect, fileTypes = ['.sim', '.prt', '.fem', '.afem'], initialPath }: FileBrowserProps) {
|
|
const [currentPath, setCurrentPath] = useState(initialPath || '');
|
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadDirectory = async (path: string) => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}&types=${fileTypes.join(',')}`);
|
|
if (!res.ok) throw new Error('Failed to load directory');
|
|
const data = await res.json();
|
|
setFiles(data.files || []);
|
|
} catch (e) {
|
|
setError('Failed to load files');
|
|
console.error(e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadDirectory(currentPath);
|
|
}
|
|
}, [isOpen, currentPath]);
|
|
|
|
const toggleExpand = (path: string) => {
|
|
setExpandedPaths(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(path)) {
|
|
next.delete(path);
|
|
} else {
|
|
next.add(path);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSelect = (file: FileEntry) => {
|
|
if (file.isDirectory) {
|
|
toggleExpand(file.path);
|
|
} else {
|
|
onSelect(file.path);
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const filteredFiles = files.filter(f =>
|
|
f.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
<div className="bg-dark-850 border border-dark-700 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
|
<h3 className="font-semibold text-white">Select Model File</h3>
|
|
<button onClick={onClose} className="p-1 text-dark-400 hover:text-white">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="px-4 py-3 border-b border-dark-700">
|
|
<div className="relative">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search files..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
|
|
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
|
|
<span>Looking for:</span>
|
|
{fileTypes.map(t => (
|
|
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">{t}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Path breadcrumb */}
|
|
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700">
|
|
<button onClick={() => setCurrentPath('')} className="hover:text-white">
|
|
studies
|
|
</button>
|
|
{currentPath.split('/').filter(Boolean).map((part, i, arr) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
<ChevronRight size={14} />
|
|
<button
|
|
onClick={() => setCurrentPath(arr.slice(0, i + 1).join('/'))}
|
|
className="hover:text-white"
|
|
>
|
|
{part}
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* File list */}
|
|
<div className="flex-1 overflow-auto p-2">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-32 text-dark-500">
|
|
<RefreshCw size={20} className="animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex items-center justify-center h-32 text-red-400">
|
|
{error}
|
|
</div>
|
|
) : filteredFiles.length === 0 ? (
|
|
<div className="flex items-center justify-center h-32 text-dark-500">
|
|
No matching files found
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{filteredFiles.map((file) => (
|
|
<button
|
|
key={file.path}
|
|
onClick={() => handleSelect(file)}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
|
|
hover:bg-dark-700 transition-colors
|
|
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
|
|
>
|
|
{file.isDirectory ? (
|
|
<>
|
|
{expandedPaths.has(file.path) ? (
|
|
<ChevronDown size={16} className="text-dark-500" />
|
|
) : (
|
|
<ChevronRight size={16} className="text-dark-500" />
|
|
)}
|
|
<Folder size={16} className="text-amber-400" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="w-4" />
|
|
<FileBox size={16} className="text-primary-400" />
|
|
</>
|
|
)}
|
|
<span className="flex-1 truncate">{file.name}</span>
|
|
{!file.isDirectory && (
|
|
<span className="text-xs text-dark-500">
|
|
{file.name.split('.').pop()?.toUpperCase()}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-4 py-3 border-t border-dark-700 flex justify-end">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-dark-300 hover:text-white transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## T4.2 - Add Backend File List Endpoint
|
|
|
|
**File:** `atomizer-dashboard/backend/api/routes/files.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, Query
|
|
from pathlib import Path
|
|
from typing import List
|
|
import os
|
|
|
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
|
|
|
STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_files(
|
|
path: str = "",
|
|
types: str = ".sim,.prt,.fem,.afem"
|
|
):
|
|
"""List files in a directory, filtered by type."""
|
|
allowed_types = [t.strip().lower() for t in types.split(',')]
|
|
|
|
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
|
|
|
|
if not base_path.exists():
|
|
return {"files": [], "path": path}
|
|
|
|
files = []
|
|
|
|
try:
|
|
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
|
if entry.name.startswith('.'):
|
|
continue
|
|
|
|
if entry.is_dir():
|
|
# Include directories
|
|
files.append({
|
|
"name": entry.name,
|
|
"path": str(entry.relative_to(STUDIES_ROOT)),
|
|
"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)),
|
|
"isDirectory": False,
|
|
})
|
|
except Exception as e:
|
|
return {"files": [], "path": path, "error": str(e)}
|
|
|
|
return {"files": files, "path": path}
|
|
```
|
|
|
|
## T4.3 - Register Files Router
|
|
|
|
**File:** `atomizer-dashboard/backend/api/main.py`
|
|
|
|
Add:
|
|
```python
|
|
from api.routes import files
|
|
|
|
app.include_router(files.router)
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 5: Implement Model Introspection
|
|
|
|
## T5.1 - Create NX Introspection Service
|
|
|
|
**File:** `atomizer-dashboard/backend/api/services/nx_introspection.py`
|
|
|
|
```python
|
|
from pathlib import Path
|
|
from typing import Dict, List, Any, Optional
|
|
import logging
|
|
import re
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
|
|
|
|
|
class NXIntrospector:
|
|
"""Introspect NX model files to discover expressions, dependencies, and solver info."""
|
|
|
|
def __init__(self, file_path: str):
|
|
self.file_path = STUDIES_ROOT / file_path
|
|
self.file_type = self.file_path.suffix.lower()
|
|
|
|
def introspect(self) -> Dict[str, Any]:
|
|
"""Full introspection of the model file."""
|
|
result = {
|
|
"file_path": str(self.file_path),
|
|
"file_type": self.file_type,
|
|
"expressions": [],
|
|
"solver_type": None,
|
|
"dependent_files": [],
|
|
"extractors_available": [],
|
|
"warnings": [],
|
|
}
|
|
|
|
if not self.file_path.exists():
|
|
result["warnings"].append(f"File not found: {self.file_path}")
|
|
return result
|
|
|
|
try:
|
|
if self.file_type == '.sim':
|
|
result.update(self._introspect_sim())
|
|
elif self.file_type == '.prt':
|
|
result.update(self._introspect_prt())
|
|
elif self.file_type in ['.fem', '.afem']:
|
|
result.update(self._introspect_fem())
|
|
except Exception as e:
|
|
logger.error(f"Introspection error: {e}")
|
|
result["warnings"].append(str(e))
|
|
|
|
# Suggest extractors based on solver type
|
|
result["extractors_available"] = self._suggest_extractors(result.get("solver_type"))
|
|
|
|
return result
|
|
|
|
def _introspect_sim(self) -> Dict[str, Any]:
|
|
"""Introspect .sim file."""
|
|
result = {
|
|
"solver_type": None,
|
|
"dependent_files": [],
|
|
"expressions": [],
|
|
}
|
|
|
|
parent_dir = self.file_path.parent
|
|
base_name = self.file_path.stem
|
|
|
|
# Find related files
|
|
for ext in ['.prt', '.fem', '.afem']:
|
|
# Look for exact match or _fem suffix variations
|
|
patterns = [
|
|
parent_dir / f"{base_name}{ext}",
|
|
parent_dir / f"{base_name.replace('_sim1', '')}{ext}",
|
|
parent_dir / f"{base_name.replace('_sim1', '_fem1')}{ext}",
|
|
]
|
|
for pattern in patterns:
|
|
if pattern.exists():
|
|
result["dependent_files"].append({
|
|
"path": str(pattern.relative_to(STUDIES_ROOT)),
|
|
"type": ext[1:], # Remove dot
|
|
})
|
|
|
|
# Find idealized part (*_i.prt)
|
|
for f in parent_dir.glob("*_i.prt"):
|
|
result["dependent_files"].append({
|
|
"path": str(f.relative_to(STUDIES_ROOT)),
|
|
"type": "idealized_prt",
|
|
})
|
|
|
|
# Try to determine solver type (would need NX API for accurate detection)
|
|
# For now, infer from file contents or naming
|
|
result["solver_type"] = self._detect_solver_type()
|
|
|
|
# Get expressions from associated .prt
|
|
prt_files = [d for d in result["dependent_files"] if d["type"] in ["prt", "idealized_prt"]]
|
|
for prt in prt_files:
|
|
prt_path = STUDIES_ROOT / prt["path"]
|
|
if prt_path.exists():
|
|
# In real implementation, would use NX API
|
|
# For now, try to detect from common expression patterns
|
|
result["expressions"].extend(self._discover_expressions_from_file(prt_path))
|
|
|
|
return result
|
|
|
|
def _introspect_prt(self) -> Dict[str, Any]:
|
|
"""Introspect .prt file."""
|
|
result = {
|
|
"expressions": [],
|
|
"dependent_files": [],
|
|
}
|
|
|
|
# Look for associated .sim and .fem files
|
|
parent_dir = self.file_path.parent
|
|
base_name = self.file_path.stem
|
|
|
|
for ext in ['.sim', '.fem', '.afem']:
|
|
patterns = [
|
|
parent_dir / f"{base_name}{ext}",
|
|
parent_dir / f"{base_name}_sim1{ext}",
|
|
parent_dir / f"{base_name}_fem1{ext}",
|
|
]
|
|
for pattern in patterns:
|
|
if pattern.exists():
|
|
result["dependent_files"].append({
|
|
"path": str(pattern.relative_to(STUDIES_ROOT)),
|
|
"type": ext[1:],
|
|
})
|
|
|
|
result["expressions"] = self._discover_expressions_from_file(self.file_path)
|
|
|
|
return result
|
|
|
|
def _introspect_fem(self) -> Dict[str, Any]:
|
|
"""Introspect .fem or .afem file."""
|
|
return {
|
|
"expressions": [],
|
|
"dependent_files": [],
|
|
}
|
|
|
|
def _detect_solver_type(self) -> Optional[str]:
|
|
"""Detect solver type from file name or contents."""
|
|
name_lower = self.file_path.name.lower()
|
|
|
|
# Infer from naming conventions
|
|
if 'modal' in name_lower or 'freq' in name_lower:
|
|
return 'SOL103' # Modal analysis
|
|
elif 'static' in name_lower or 'stress' in name_lower:
|
|
return 'SOL101' # Static analysis
|
|
elif 'thermal' in name_lower or 'heat' in name_lower:
|
|
return 'SOL153' # Thermal
|
|
elif 'dynamic' in name_lower:
|
|
return 'SOL111' # Frequency response
|
|
|
|
# Default to static
|
|
return 'SOL101'
|
|
|
|
def _discover_expressions_from_file(self, file_path: Path) -> List[Dict[str, Any]]:
|
|
"""Discover expressions from a part file."""
|
|
# In real implementation, this would use NX Open API
|
|
# For now, return common expression patterns for demo
|
|
common_expressions = [
|
|
{"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "length", "value": 100.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "width", "value": 50.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "height", "value": 25.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "wall_thickness", "value": 2.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "rib_height", "value": 5.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension"},
|
|
{"name": "hole_diameter", "value": 8.0, "unit": "mm", "type": "dimension"},
|
|
]
|
|
|
|
# Filter to likely candidates based on file name
|
|
return common_expressions
|
|
|
|
def _suggest_extractors(self, solver_type: Optional[str]) -> List[Dict[str, Any]]:
|
|
"""Suggest extractors based on solver type."""
|
|
extractors = [
|
|
{"id": "E4", "name": "Mass (BDF)", "always": True},
|
|
{"id": "E5", "name": "Mass (Expression)", "always": True},
|
|
]
|
|
|
|
if solver_type == 'SOL101':
|
|
extractors.extend([
|
|
{"id": "E1", "name": "Displacement", "always": False},
|
|
{"id": "E3", "name": "Stress", "always": False},
|
|
])
|
|
elif solver_type == 'SOL103':
|
|
extractors.extend([
|
|
{"id": "E2", "name": "Frequency", "always": False},
|
|
])
|
|
|
|
# Always include Zernike for mirrors
|
|
extractors.extend([
|
|
{"id": "E8", "name": "Zernike Coefficients", "always": False},
|
|
{"id": "E9", "name": "Zernike RMS", "always": False},
|
|
{"id": "E10", "name": "Zernike WFE", "always": False},
|
|
])
|
|
|
|
return extractors
|
|
```
|
|
|
|
## T5.2 - Create Introspection API Endpoint
|
|
|
|
**File:** `atomizer-dashboard/backend/api/routes/nx.py`
|
|
|
|
```python
|
|
from fastapi import APIRouter, HTTPException
|
|
from api.services.nx_introspection import NXIntrospector
|
|
|
|
router = APIRouter(prefix="/api/nx", tags=["nx"])
|
|
|
|
|
|
@router.post("/introspect")
|
|
async def introspect_model(file_path: str):
|
|
"""Introspect an NX model file to discover expressions, solver type, and dependencies."""
|
|
try:
|
|
introspector = NXIntrospector(file_path)
|
|
result = introspector.introspect()
|
|
return result
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/expressions")
|
|
async def get_expressions(file_path: str):
|
|
"""Get expressions from an NX model."""
|
|
try:
|
|
introspector = NXIntrospector(file_path)
|
|
result = introspector.introspect()
|
|
return {
|
|
"expressions": result.get("expressions", []),
|
|
"file_path": file_path,
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
```
|
|
|
|
## T5.3 - Register NX Router
|
|
|
|
**File:** `atomizer-dashboard/backend/api/main.py`
|
|
|
|
Add:
|
|
```python
|
|
from api.routes import nx
|
|
|
|
app.include_router(nx.router)
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 6: Add Introspection Panel to Model Config
|
|
|
|
## T6.1 - Create Introspection Panel Component
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx`
|
|
|
|
```typescript
|
|
import { useState, useEffect } from 'react';
|
|
import { X, Search, RefreshCw, Plus, ChevronDown, ChevronRight, FileBox, Cpu, FlaskConical, SlidersHorizontal } from 'lucide-react';
|
|
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
|
|
|
interface IntrospectionPanelProps {
|
|
filePath: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface IntrospectionResult {
|
|
file_path: string;
|
|
file_type: string;
|
|
expressions: Array<{
|
|
name: string;
|
|
value: number;
|
|
unit: string;
|
|
type: string;
|
|
}>;
|
|
solver_type: string | null;
|
|
dependent_files: Array<{
|
|
path: string;
|
|
type: string;
|
|
}>;
|
|
extractors_available: Array<{
|
|
id: string;
|
|
name: string;
|
|
always: boolean;
|
|
}>;
|
|
warnings: string[];
|
|
}
|
|
|
|
export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) {
|
|
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['expressions', 'extractors']));
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
const { addNode, nodes, edges } = useCanvasStore();
|
|
|
|
const runIntrospection = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch('/api/nx/introspect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_path: filePath }),
|
|
});
|
|
if (!res.ok) throw new Error('Introspection failed');
|
|
const data = await res.json();
|
|
setResult(data);
|
|
} catch (e) {
|
|
setError('Failed to introspect model');
|
|
console.error(e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (filePath) {
|
|
runIntrospection();
|
|
}
|
|
}, [filePath]);
|
|
|
|
const toggleSection = (section: string) => {
|
|
setExpandedSections(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(section)) next.delete(section);
|
|
else next.add(section);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const addExpressionAsDesignVar = (expr: IntrospectionResult['expressions'][0]) => {
|
|
// Find a good position (left of model node)
|
|
const modelNode = nodes.find(n => n.data.type === 'model');
|
|
const existingDvars = nodes.filter(n => n.data.type === 'designVar');
|
|
|
|
const position = {
|
|
x: (modelNode?.position.x || 300) - 250,
|
|
y: (modelNode?.position.y || 100) + existingDvars.length * 100,
|
|
};
|
|
|
|
addNode('designVar', position, {
|
|
label: expr.name,
|
|
expressionName: expr.name,
|
|
minValue: expr.value * 0.5,
|
|
maxValue: expr.value * 1.5,
|
|
unit: expr.unit,
|
|
configured: true,
|
|
});
|
|
};
|
|
|
|
const addExtractor = (extractor: IntrospectionResult['extractors_available'][0]) => {
|
|
// Find a good position (right of solver node)
|
|
const solverNode = nodes.find(n => n.data.type === 'solver');
|
|
const existingExtractors = nodes.filter(n => n.data.type === 'extractor');
|
|
|
|
const position = {
|
|
x: (solverNode?.position.x || 400) + 200,
|
|
y: (solverNode?.position.y || 100) + existingExtractors.length * 100,
|
|
};
|
|
|
|
addNode('extractor', position, {
|
|
label: extractor.name,
|
|
extractorId: extractor.id,
|
|
extractorName: extractor.name,
|
|
configured: true,
|
|
});
|
|
};
|
|
|
|
const filteredExpressions = result?.expressions.filter(e =>
|
|
e.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
) || [];
|
|
|
|
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</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={runIntrospection}
|
|
disabled={isLoading}
|
|
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded"
|
|
>
|
|
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
|
</button>
|
|
<button onClick={onClose} className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="px-4 py-2 border-b border-dark-700">
|
|
<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}</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"
|
|
>
|
|
<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">
|
|
{filteredExpressions.map((expr) => (
|
|
<div
|
|
key={expr.name}
|
|
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group"
|
|
>
|
|
<div>
|
|
<p className="text-sm text-white">{expr.name}</p>
|
|
<p className="text-xs text-dark-500">
|
|
{expr.value} {expr.unit}
|
|
</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-opacity"
|
|
title="Add as Design Variable"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Extractors Section */}
|
|
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => toggleSection('extractors')}
|
|
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FlaskConical size={14} className="text-cyan-400" />
|
|
<span className="text-sm font-medium text-white">
|
|
Available Extractors ({result.extractors_available.length})
|
|
</span>
|
|
</div>
|
|
{expandedSections.has('extractors') ? (
|
|
<ChevronDown size={14} className="text-dark-400" />
|
|
) : (
|
|
<ChevronRight size={14} className="text-dark-400" />
|
|
)}
|
|
</button>
|
|
|
|
{expandedSections.has('extractors') && (
|
|
<div className="p-2 space-y-1">
|
|
{result.extractors_available.map((ext) => (
|
|
<div
|
|
key={ext.id}
|
|
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group"
|
|
>
|
|
<div>
|
|
<p className="text-sm text-white">{ext.name}</p>
|
|
<p className="text-xs text-dark-500">{ext.id}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => addExtractor(ext)}
|
|
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
|
|
opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Add Extractor"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dependent Files */}
|
|
{result.dependent_files.length > 0 && (
|
|
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => toggleSection('files')}
|
|
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FileBox size={14} className="text-amber-400" />
|
|
<span className="text-sm font-medium text-white">
|
|
Dependent Files ({result.dependent_files.length})
|
|
</span>
|
|
</div>
|
|
{expandedSections.has('files') ? (
|
|
<ChevronDown size={14} className="text-dark-400" />
|
|
) : (
|
|
<ChevronRight size={14} className="text-dark-400" />
|
|
)}
|
|
</button>
|
|
|
|
{expandedSections.has('files') && (
|
|
<div className="p-2 space-y-1">
|
|
{result.dependent_files.map((file) => (
|
|
<div
|
|
key={file.path}
|
|
className="flex items-center gap-2 p-2 bg-dark-850 rounded"
|
|
>
|
|
<FileBox size={14} className="text-dark-400" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-white truncate">{file.path.split('/').pop()}</p>
|
|
<p className="text-xs text-dark-500">{file.type}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Warnings */}
|
|
{result.warnings.length > 0 && (
|
|
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
|
<p className="text-xs text-amber-400 font-medium mb-1">Warnings</p>
|
|
{result.warnings.map((w, i) => (
|
|
<p key={i} className="text-xs text-amber-300">{w}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-32 text-dark-500 text-sm">
|
|
Select a model to introspect
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 7: Integrate Into NodeConfigPanel
|
|
|
|
## T7.1 - Update Model Node Config
|
|
|
|
**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx`
|
|
|
|
Add file browser and introspection button to Model node configuration:
|
|
|
|
```typescript
|
|
// Add to imports
|
|
import { FileBrowser } from './FileBrowser';
|
|
import { IntrospectionPanel } from './IntrospectionPanel';
|
|
import { FolderSearch, Microscope } from 'lucide-react';
|
|
|
|
// In the component, add state
|
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
|
const [showIntrospection, setShowIntrospection] = useState(false);
|
|
|
|
// In the Model node config section, add:
|
|
{selectedNode.data.type === 'model' && (
|
|
<div className="space-y-3">
|
|
{/* File Path with Browse Button */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-dark-400 mb-1">
|
|
Model File
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={selectedNode.data.filePath || ''}
|
|
onChange={(e) => updateData('filePath', e.target.value)}
|
|
placeholder="path/to/model.sim"
|
|
className="flex-1 px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
|
text-sm text-white placeholder-dark-500"
|
|
/>
|
|
<button
|
|
onClick={() => setShowFileBrowser(true)}
|
|
className="px-2 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-dark-300 hover:text-white"
|
|
title="Browse files"
|
|
>
|
|
<FolderSearch size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Introspect Button */}
|
|
{selectedNode.data.filePath && (
|
|
<button
|
|
onClick={() => setShowIntrospection(true)}
|
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 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"
|
|
>
|
|
<Microscope size={16} />
|
|
Introspect Model
|
|
</button>
|
|
)}
|
|
|
|
{/* File Type Display */}
|
|
{selectedNode.data.fileType && (
|
|
<div className="text-xs text-dark-500">
|
|
Type: {selectedNode.data.fileType.toUpperCase()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
// Add dialogs at the end of the component
|
|
{showFileBrowser && (
|
|
<FileBrowser
|
|
isOpen={showFileBrowser}
|
|
onClose={() => setShowFileBrowser(false)}
|
|
onSelect={(path) => {
|
|
updateData('filePath', path);
|
|
updateData('fileType', path.split('.').pop());
|
|
setShowFileBrowser(false);
|
|
}}
|
|
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
|
/>
|
|
)}
|
|
|
|
{showIntrospection && selectedNode.data.filePath && (
|
|
<div className="fixed top-20 right-4 z-40">
|
|
<IntrospectionPanel
|
|
filePath={selectedNode.data.filePath}
|
|
onClose={() => setShowIntrospection(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
# PHASE 8: Build, Test, and Commit
|
|
|
|
## T8.1 - Build Frontend
|
|
|
|
```bash
|
|
cd atomizer-dashboard/frontend
|
|
npm run build
|
|
```
|
|
|
|
## T8.2 - Test Backend
|
|
|
|
```bash
|
|
cd atomizer-dashboard/backend
|
|
python -c "
|
|
from api.services.conversation_store import ConversationStore
|
|
from api.services.nx_introspection import NXIntrospector
|
|
|
|
# Test DB
|
|
store = ConversationStore()
|
|
session = store.get_or_create_session('test')
|
|
print('DB OK:', session)
|
|
|
|
# Test introspection
|
|
# introspector = NXIntrospector('test.sim')
|
|
print('Backend modules OK')
|
|
"
|
|
```
|
|
|
|
## T8.3 - Git Commit
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "feat: Canvas V4 - Model introspection and Claude integration fixes
|
|
|
|
## Critical Bug Fixes
|
|
- Fix Claude bot SQL error with robust database initialization
|
|
- Fix WebSocket chat connection with proper error handling and reconnect
|
|
- Add health check endpoint for database status
|
|
|
|
## Model Introspection
|
|
- Add NX model introspection service (expressions, solver type, dependencies)
|
|
- Create Introspection Panel with collapsible sections
|
|
- Add File Browser component for .sim/.prt/.fem/.afem selection
|
|
- One-click add expressions as Design Variables
|
|
- One-click add suggested Extractors
|
|
|
|
## Connection Flow Fix
|
|
- Update node handles: expressions flow INTO model (not out)
|
|
- Update validation for new data flow direction
|
|
- Multiple input handles on Model node for design variables
|
|
|
|
## Backend Additions
|
|
- /api/files/list - Directory browser with type filtering
|
|
- /api/nx/introspect - Model introspection endpoint
|
|
- /api/nx/expressions - Expression discovery endpoint
|
|
|
|
## Frontend Improvements
|
|
- FileBrowser: File picker with search and type filtering
|
|
- IntrospectionPanel: Shows expressions, extractors, dependencies
|
|
- NodeConfigPanel: Integrated file browser and introspection
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
|
|
git push origin main && git push github main
|
|
```
|
|
|
|
---
|
|
|
|
# ACCEPTANCE CRITERIA
|
|
|
|
## Critical Fixes
|
|
- [ ] No SQL error when clicking Validate
|
|
- [ ] Chat panel connects and responds
|
|
- [ ] WebSocket reconnects on disconnect
|
|
|
|
## File Browser
|
|
- [ ] Browse button opens file picker
|
|
- [ ] Can navigate directories
|
|
- [ ] Filters to .sim/.prt/.fem/.afem
|
|
- [ ] Selecting file populates path
|
|
|
|
## Model Introspection
|
|
- [ ] "Introspect Model" button works
|
|
- [ ] Shows discovered expressions
|
|
- [ ] Shows available extractors
|
|
- [ ] Shows dependent files
|
|
- [ ] Can add expression as Design Variable
|
|
- [ ] Can add suggested Extractor
|
|
|
|
## Connection Flow
|
|
- [ ] Design Variables connect TO Model (not from)
|
|
- [ ] Validation checks correct flow direction
|
|
|
|
## Build
|
|
- [ ] `npm run build` passes
|
|
- [ ] Backend imports work
|
|
|
|
---
|
|
|
|
# BEGIN EXECUTION
|
|
|
|
Execute all 8 phases sequentially. Use TodoWrite for every task. Complete fully before moving to next phase. Do not stop.
|
|
|
|
**GO.**
|