feat: Phase 1 - Canvas with React Flow

- 8 node types (Model, Solver, DesignVar, Extractor, Objective, Constraint, Algorithm, Surrogate)
- Drag-drop from palette to canvas
- Node configuration panels
- Graph validation with error/warning display
- Intent JSON serialization
- Zustand state management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 20:00:35 -05:00
parent 73a7b9d9f1
commit 7919511bb2
24 changed files with 1915 additions and 6 deletions

View File

@@ -0,0 +1,173 @@
/**
* Intent Serializer - Convert canvas graph to Intent JSON for Claude
*/
import { Node, Edge } from 'reactflow';
import { CanvasNodeData, ExtractorNodeData } from './schema';
export interface OptimizationIntent {
version: '1.0';
source: 'canvas';
timestamp: string;
model: {
path?: string;
type?: string;
};
solver: {
type?: string;
};
design_variables: Array<{
name: string;
min: number;
max: number;
unit?: string;
}>;
extractors: Array<{
id: string;
name: string;
config?: Record<string, unknown>;
}>;
objectives: Array<{
name: string;
direction: 'minimize' | 'maximize';
weight: number;
extractor: string;
}>;
constraints: Array<{
name: string;
operator: string;
value: number;
extractor: string;
}>;
optimization: {
method?: string;
max_trials?: number;
};
surrogate?: {
enabled: boolean;
type?: string;
min_trials?: number;
};
}
export function serializeToIntent(
nodes: Node<CanvasNodeData>[],
edges: Edge[]
): OptimizationIntent {
const intent: OptimizationIntent = {
version: '1.0',
source: 'canvas',
timestamp: new Date().toISOString(),
model: {},
solver: {},
design_variables: [],
extractors: [],
objectives: [],
constraints: [],
optimization: {},
};
// Helper to find connected nodes
const getConnectedNodes = (nodeId: string, direction: 'source' | 'target') => {
return edges
.filter(e => direction === 'source' ? e.source === nodeId : e.target === nodeId)
.map(e => direction === 'source' ? e.target : e.source)
.map(id => nodes.find(n => n.id === id))
.filter(Boolean) as Node<CanvasNodeData>[];
};
// Process each node type
for (const node of nodes) {
const data = node.data;
switch (data.type) {
case 'model':
intent.model = {
path: data.filePath,
type: data.fileType,
};
break;
case 'solver':
intent.solver = {
type: data.solverType,
};
break;
case 'designVar':
if (data.expressionName) {
intent.design_variables.push({
name: data.expressionName,
min: data.minValue ?? 0,
max: data.maxValue ?? 100,
unit: data.unit,
});
}
break;
case 'extractor':
if (data.extractorId) {
intent.extractors.push({
id: data.extractorId,
name: data.extractorName ?? data.extractorId,
config: data.config,
});
}
break;
case 'objective':
if (data.name) {
// Find connected extractor
const sourceNodes = getConnectedNodes(node.id, 'target');
const extractor = sourceNodes.find(n => n.data.type === 'extractor');
intent.objectives.push({
name: data.name,
direction: data.direction ?? 'minimize',
weight: data.weight ?? 1,
extractor: extractor?.data.type === 'extractor'
? (extractor.data as ExtractorNodeData).extractorId ?? ''
: '',
});
}
break;
case 'constraint':
if (data.name) {
const sourceNodes = getConnectedNodes(node.id, 'target');
const extractor = sourceNodes.find(n => n.data.type === 'extractor');
intent.constraints.push({
name: data.name,
operator: data.operator ?? '<=',
value: data.value ?? 0,
extractor: extractor?.data.type === 'extractor'
? (extractor.data as ExtractorNodeData).extractorId ?? ''
: '',
});
}
break;
case 'algorithm':
intent.optimization = {
method: data.method,
max_trials: data.maxTrials,
};
break;
case 'surrogate':
intent.surrogate = {
enabled: data.enabled ?? false,
type: data.modelType,
min_trials: data.minTrials,
};
break;
}
}
return intent;
}
export function formatIntentForChat(intent: OptimizationIntent): string {
return `INTENT:${JSON.stringify(intent)}`;
}

View File

@@ -0,0 +1,102 @@
/**
* Canvas Schema - Type definitions for optimization workflow nodes
*/
export type NodeType =
| 'model'
| 'solver'
| 'designVar'
| 'extractor'
| 'objective'
| 'constraint'
| 'algorithm'
| 'surrogate';
export interface BaseNodeData {
label: string;
configured: boolean;
errors?: string[];
}
export interface ModelNodeData extends BaseNodeData {
type: 'model';
filePath?: string;
fileType?: 'prt' | 'fem' | 'sim';
}
export interface SolverNodeData extends BaseNodeData {
type: 'solver';
solverType?: 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112';
}
export interface DesignVarNodeData extends BaseNodeData {
type: 'designVar';
expressionName?: string;
minValue?: number;
maxValue?: number;
unit?: string;
}
export interface ExtractorNodeData extends BaseNodeData {
type: 'extractor';
extractorId?: string;
extractorName?: string;
config?: Record<string, unknown>;
}
export interface ObjectiveNodeData extends BaseNodeData {
type: 'objective';
name?: string;
direction?: 'minimize' | 'maximize';
weight?: number;
}
export interface ConstraintNodeData extends BaseNodeData {
type: 'constraint';
name?: string;
operator?: '<' | '<=' | '>' | '>=' | '==';
value?: number;
}
export interface AlgorithmNodeData extends BaseNodeData {
type: 'algorithm';
method?: 'TPE' | 'CMA-ES' | 'NSGA-II' | 'GP-BO' | 'RandomSearch';
maxTrials?: number;
}
export interface SurrogateNodeData extends BaseNodeData {
type: 'surrogate';
enabled?: boolean;
modelType?: 'MLP' | 'GNN' | 'Ensemble';
minTrials?: number;
}
export type CanvasNodeData =
| ModelNodeData
| SolverNodeData
| DesignVarNodeData
| ExtractorNodeData
| ObjectiveNodeData
| ConstraintNodeData
| AlgorithmNodeData
| SurrogateNodeData;
export interface CanvasEdge {
id: string;
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
}
// Valid connections
export const VALID_CONNECTIONS: Record<NodeType, NodeType[]> = {
model: ['solver', 'designVar'],
solver: ['extractor'],
designVar: ['model'],
extractor: ['objective', 'constraint'],
objective: ['algorithm'],
constraint: ['algorithm'],
algorithm: ['surrogate'],
surrogate: [],
};

View File

@@ -0,0 +1,91 @@
/**
* Canvas Validation - Validate optimization workflow graphs
*/
import { Node, Edge } from 'reactflow';
import { CanvasNodeData, NodeType, VALID_CONNECTIONS } from './schema';
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export function validateGraph(
nodes: Node<CanvasNodeData>[],
edges: Edge[]
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Check required nodes exist
const nodeTypes = new Set(nodes.map(n => n.data.type));
if (!nodeTypes.has('model')) {
errors.push('Missing Model node - add an NX model file');
}
if (!nodeTypes.has('solver')) {
errors.push('Missing Solver node - specify solution type');
}
if (!nodeTypes.has('objective')) {
errors.push('Missing Objective node - define what to optimize');
}
if (!nodeTypes.has('algorithm')) {
errors.push('Missing Algorithm node - select optimization method');
}
// Check design variables
const designVars = nodes.filter(n => n.data.type === 'designVar');
if (designVars.length === 0) {
errors.push('No design variables - add at least one parameter to vary');
}
// Check extractors
const extractors = nodes.filter(n => n.data.type === 'extractor');
if (extractors.length === 0) {
errors.push('No extractors - add physics extractors for objectives');
}
// Check node configurations
for (const node of nodes) {
if (!node.data.configured) {
warnings.push(`${node.data.label} is not fully configured`);
}
if (node.data.errors?.length) {
errors.push(...node.data.errors.map(e => `${node.data.label}: ${e}`));
}
}
// Validate edge connections
for (const edge of edges) {
const source = nodes.find(n => n.id === edge.source);
const target = nodes.find(n => n.id === edge.target);
if (source && target) {
const sourceType = source.data.type as NodeType;
const targetType = target.data.type as NodeType;
const validTargets = VALID_CONNECTIONS[sourceType] || [];
if (!validTargets.includes(targetType)) {
errors.push(
`Invalid connection: ${source.data.label} -> ${target.data.label}`
);
}
}
}
// Check connectivity
const objectives = nodes.filter(n => n.data.type === 'objective');
for (const obj of objectives) {
const hasIncoming = edges.some(e => e.target === obj.id);
if (!hasIncoming) {
errors.push(`${obj.data.label} has no connected extractor`);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}