feat(canvas): Add Phase 3+4 - Bidirectional sync, templates, and visual polish

Phase 3 - Bidirectional Sync:
- Add loadFromIntent and loadFromConfig to canvas store
- Create useIntentParser hook for parsing Claude messages
- Create ConfigImporter component (file upload, paste JSON, load study)
- Add import/clear buttons to CanvasView header

Phase 4 - Templates & Polish:
- Create template library with 5 presets:
  - Mass Minimization (single-objective)
  - Multi-Objective Pareto (NSGA-II)
  - Turbo Mode (with MLP surrogate)
  - Mirror Zernike (optical optimization)
  - Frequency Optimization (modal)
- Create TemplateSelector component with category filters
- Enhanced BaseNode with animations, glow effects, status indicators
- Add colorBg to all node types for visual distinction
- Add notification toast system
- Update all exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 20:30:28 -05:00
parent 1ae35382da
commit 5bd142780f
18 changed files with 1389 additions and 35 deletions

View File

@@ -0,0 +1,5 @@
// Canvas Hooks
export { useCanvasStore } from './useCanvasStore';
export type { OptimizationConfig } from './useCanvasStore';
export { useCanvasChat } from './useCanvasChat';
export { useIntentParser } from './useIntentParser';

View File

@@ -22,6 +22,45 @@ interface CanvasState {
toIntent: () => OptimizationIntent;
clear: () => void;
loadFromIntent: (intent: OptimizationIntent) => void;
loadFromConfig: (config: OptimizationConfig) => void;
}
// Optimization config structure (from optimization_config.json)
export interface OptimizationConfig {
study_name?: string;
model?: {
path?: string;
type?: string;
};
solver?: {
type?: string;
solution?: number;
};
design_variables?: Array<{
name: string;
expression_name?: string;
lower: number;
upper: number;
type?: string;
}>;
objectives?: Array<{
name: string;
direction?: string;
weight?: number;
extractor?: string;
}>;
constraints?: Array<{
name: string;
type?: string;
value?: number;
extractor?: string;
}>;
method?: string;
max_trials?: number;
surrogate?: {
type?: string;
min_trials?: number;
} | null;
}
let nodeIdCounter = 0;
@@ -43,6 +82,14 @@ const getDefaultData = (type: NodeType): CanvasNodeData => {
}
};
// Layout constants for auto-arrangement
const LAYOUT = {
startX: 100,
startY: 100,
colWidth: 250,
rowHeight: 120,
};
export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes: [],
edges: [],
@@ -119,7 +166,216 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
loadFromIntent: (intent) => {
// TODO: Implement reverse serialization
console.log('Loading intent:', intent);
// Clear existing
nodeIdCounter = 0;
const nodes: Node<CanvasNodeData>[] = [];
const edges: Edge[] = [];
let col = 0;
let row = 0;
// Helper to create positioned node
const createNode = (type: NodeType, data: Partial<CanvasNodeData>, colOffset = 0): string => {
const id = getNodeId();
nodes.push({
id,
type,
position: {
x: LAYOUT.startX + (col + colOffset) * LAYOUT.colWidth,
y: LAYOUT.startY + row * LAYOUT.rowHeight,
},
data: { ...getDefaultData(type), ...data, configured: true } as CanvasNodeData,
});
return id;
};
// Model node (column 0)
col = 0;
const modelId = createNode('model', {
label: 'Model',
filePath: intent.model?.path,
fileType: intent.model?.type as 'prt' | 'fem' | 'sim' | undefined,
});
// Solver node (column 1)
col = 1;
const solverId = createNode('solver', {
label: 'Solver',
solverType: intent.solver?.type as any,
});
edges.push({ id: `e_${modelId}_${solverId}`, source: modelId, target: solverId });
// Design variables (column 0, multiple rows)
col = 0;
row = 1;
const dvIds: string[] = [];
for (const dv of intent.design_variables || []) {
const dvId = createNode('designVar', {
label: dv.name,
expressionName: dv.name,
minValue: dv.min,
maxValue: dv.max,
unit: dv.unit,
});
dvIds.push(dvId);
edges.push({ id: `e_${dvId}_${modelId}`, source: dvId, target: modelId });
row++;
}
// Extractors (column 2)
col = 2;
row = 0;
const extractorMap: Record<string, string> = {};
for (const ext of intent.extractors || []) {
const extId = createNode('extractor', {
label: ext.name,
extractorId: ext.id,
extractorName: ext.name,
config: ext.config,
});
extractorMap[ext.id] = extId;
edges.push({ id: `e_${solverId}_${extId}`, source: solverId, target: extId });
row++;
}
// Objectives (column 3)
col = 3;
row = 0;
const objIds: string[] = [];
for (const obj of intent.objectives || []) {
const objId = createNode('objective', {
label: obj.name,
name: obj.name,
direction: obj.direction,
weight: obj.weight,
});
objIds.push(objId);
// Connect to extractor if specified
if (obj.extractor && extractorMap[obj.extractor]) {
edges.push({ id: `e_${extractorMap[obj.extractor]}_${objId}`, source: extractorMap[obj.extractor], target: objId });
}
row++;
}
// Constraints (column 3, after objectives)
const conIds: string[] = [];
for (const con of intent.constraints || []) {
const conId = createNode('constraint', {
label: con.name,
name: con.name,
operator: con.operator as any,
value: con.value,
});
conIds.push(conId);
if (con.extractor && extractorMap[con.extractor]) {
edges.push({ id: `e_${extractorMap[con.extractor]}_${conId}`, source: extractorMap[con.extractor], target: conId });
}
row++;
}
// Algorithm (column 4)
col = 4;
row = 0;
const algoId = createNode('algorithm', {
label: 'Algorithm',
method: intent.optimization?.method as any,
maxTrials: intent.optimization?.max_trials,
});
// Connect all objectives and constraints to algorithm
for (const objId of objIds) {
edges.push({ id: `e_${objId}_${algoId}`, source: objId, target: algoId });
}
for (const conId of conIds) {
edges.push({ id: `e_${conId}_${algoId}`, source: conId, target: algoId });
}
// Surrogate (column 5, optional)
if (intent.surrogate?.enabled) {
col = 5;
const surId = createNode('surrogate', {
label: 'Surrogate',
enabled: true,
modelType: intent.surrogate.type as any,
minTrials: intent.surrogate.min_trials,
});
edges.push({ id: `e_${algoId}_${surId}`, source: algoId, target: surId });
}
set({
nodes,
edges,
selectedNode: null,
validation: { valid: false, errors: [], warnings: [] },
});
},
loadFromConfig: (config) => {
// Convert optimization_config.json format to intent format, then load
const intent: OptimizationIntent = {
version: '1.0',
source: 'canvas',
timestamp: new Date().toISOString(),
model: {
path: config.model?.path,
type: config.model?.type,
},
solver: {
type: config.solver?.solution ? `SOL${config.solver.solution}` : undefined,
},
design_variables: (config.design_variables || []).map(dv => ({
name: dv.expression_name || dv.name,
min: dv.lower,
max: dv.upper,
})),
extractors: [], // Will be inferred from objectives
objectives: (config.objectives || []).map(obj => ({
name: obj.name,
direction: (obj.direction as 'minimize' | 'maximize') || 'minimize',
weight: obj.weight || 1,
extractor: obj.extractor || '',
})),
constraints: (config.constraints || []).map(con => ({
name: con.name,
operator: con.type === 'upper' ? '<=' : '>=',
value: con.value || 0,
extractor: con.extractor || '',
})),
optimization: {
method: config.method,
max_trials: config.max_trials,
},
surrogate: config.surrogate ? {
enabled: true,
type: config.surrogate.type,
min_trials: config.surrogate.min_trials,
} : undefined,
};
// Infer extractors from objectives and constraints
const extractorIds = new Set<string>();
for (const obj of intent.objectives) {
if (obj.extractor) extractorIds.add(obj.extractor);
}
for (const con of intent.constraints) {
if (con.extractor) extractorIds.add(con.extractor);
}
const extractorNames: Record<string, string> = {
'E1': 'Displacement',
'E2': 'Frequency',
'E3': 'Solid Stress',
'E4': 'BDF Mass',
'E5': 'CAD Mass',
'E8': 'Zernike (OP2)',
'E9': 'Zernike (CSV)',
'E10': 'Zernike (RMS)',
};
intent.extractors = Array.from(extractorIds).map(id => ({
id,
name: extractorNames[id] || id,
}));
get().loadFromIntent(intent);
},
}));

View File

@@ -0,0 +1,172 @@
/**
* useIntentParser - Parse Claude messages for canvas updates
*
* Detects structured intents in chat messages and applies them to canvas
*/
import { useCallback } from 'react';
import { useCanvasStore, OptimizationConfig } from './useCanvasStore';
import { OptimizationIntent } from '../lib/canvas/intent';
import { Message } from '../components/chat/ChatMessage';
interface ParsedUpdate {
type: 'intent' | 'config' | 'suggestion' | 'none';
data?: OptimizationIntent | OptimizationConfig;
suggestions?: string[];
}
export function useIntentParser() {
const { loadFromIntent, loadFromConfig, updateNodeData, nodes } = useCanvasStore();
/**
* Parse a message for structured intent JSON
*/
const parseIntent = useCallback((content: string): ParsedUpdate => {
// Look for JSON blocks in the message
const jsonBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/g;
const matches = [...content.matchAll(jsonBlockRegex)];
for (const match of matches) {
try {
const parsed = JSON.parse(match[1]);
// Check if it's an OptimizationIntent
if (parsed.version && parsed.source === 'canvas') {
return { type: 'intent', data: parsed as OptimizationIntent };
}
// Check if it's an OptimizationConfig (has design_variables or objectives)
if (parsed.design_variables || parsed.objectives || parsed.model?.path) {
return { type: 'config', data: parsed as OptimizationConfig };
}
} catch {
// Not valid JSON, continue
}
}
// Look for inline JSON (without code blocks)
const inlineJsonRegex = /\{[\s\S]*?"(?:version|design_variables|objectives)"[\s\S]*?\}/g;
const inlineMatches = content.match(inlineJsonRegex);
if (inlineMatches) {
for (const jsonStr of inlineMatches) {
try {
const parsed = JSON.parse(jsonStr);
if (parsed.version && parsed.source === 'canvas') {
return { type: 'intent', data: parsed as OptimizationIntent };
}
if (parsed.design_variables || parsed.objectives) {
return { type: 'config', data: parsed as OptimizationConfig };
}
} catch {
// Not valid JSON
}
}
}
// Look for suggestions in the message
const suggestions = extractSuggestions(content);
if (suggestions.length > 0) {
return { type: 'suggestion', suggestions };
}
return { type: 'none' };
}, []);
/**
* Extract actionable suggestions from Claude's response
*/
const extractSuggestions = (content: string): string[] => {
const suggestions: string[] = [];
// Common suggestion patterns
const patterns = [
/suggest(?:ing)?\s+(?:to\s+)?(?:add|include|use)\s+([^.!?\n]+)/gi,
/recommend(?:ing)?\s+([^.!?\n]+)/gi,
/consider\s+(?:adding|using|including)\s+([^.!?\n]+)/gi,
/you\s+(?:should|could|might)\s+(?:add|include|use)\s+([^.!?\n]+)/gi,
];
for (const pattern of patterns) {
const matches = content.matchAll(pattern);
for (const match of matches) {
if (match[1]) {
suggestions.push(match[1].trim());
}
}
}
return suggestions;
};
/**
* Apply a parsed update to the canvas
*/
const applyUpdate = useCallback((update: ParsedUpdate): boolean => {
switch (update.type) {
case 'intent':
if (update.data && 'version' in update.data) {
loadFromIntent(update.data as OptimizationIntent);
return true;
}
break;
case 'config':
if (update.data && !('version' in update.data)) {
loadFromConfig(update.data as OptimizationConfig);
return true;
}
break;
case 'suggestion':
// Suggestions are returned but not auto-applied
return false;
}
return false;
}, [loadFromIntent, loadFromConfig]);
/**
* Process a message and optionally apply updates
*/
const processMessage = useCallback((message: Message, autoApply = false): ParsedUpdate => {
const update = parseIntent(message.content);
if (autoApply && (update.type === 'intent' || update.type === 'config')) {
applyUpdate(update);
}
return update;
}, [parseIntent, applyUpdate]);
/**
* Check if a message contains canvas-related content
*/
const hasCanvasContent = useCallback((content: string): boolean => {
const keywords = [
'design variable', 'objective', 'constraint', 'extractor',
'optimization', 'algorithm', 'surrogate', 'model', 'solver',
'canvas', 'workflow', 'node'
];
const lowerContent = content.toLowerCase();
return keywords.some(kw => lowerContent.includes(kw));
}, []);
/**
* Apply parameter updates to specific nodes
*/
const applyParameterUpdates = useCallback((updates: Record<string, Partial<unknown>>) => {
for (const [nodeId, data] of Object.entries(updates)) {
const node = nodes.find(n => n.id === nodeId);
if (node) {
updateNodeData(nodeId, data as Record<string, unknown>);
}
}
}, [nodes, updateNodeData]);
return {
parseIntent,
applyUpdate,
processMessage,
hasCanvasContent,
applyParameterUpdates,
extractSuggestions,
};
}