feat: Phase 2 - LLM Integration for Canvas
- Add canvas.ts MCP tool with validate_canvas_intent, execute_canvas_intent, interpret_canvas_intent - Add useCanvasChat.ts bridge hook connecting canvas to chat system - Update context_builder.py with canvas tool instructions - Add ExecuteDialog for study name input - Add ChatPanel for canvas-integrated Claude responses - Connect AtomizerCanvas to Claude via useCanvasChat Canvas workflow now: 1. Build graph visually 2. Click Validate/Analyze/Execute 3. Claude processes intent via MCP tools 4. Response shown in integrated chat panel Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { optimizationTools } from "./tools/optimization.js";
|
||||
import { analysisTools } from "./tools/analysis.js";
|
||||
import { reportingTools } from "./tools/reporting.js";
|
||||
import { physicsTools } from "./tools/physics.js";
|
||||
import { canvasTools } from "./tools/canvas.js";
|
||||
import { adminTools } from "./tools/admin.js";
|
||||
import { ATOMIZER_MODE } from "./utils/paths.js";
|
||||
|
||||
@@ -50,6 +51,7 @@ const userTools: AtomizerTool[] = [
|
||||
...analysisTools,
|
||||
...reportingTools,
|
||||
...physicsTools,
|
||||
...canvasTools,
|
||||
];
|
||||
|
||||
const powerTools: AtomizerTool[] = [
|
||||
|
||||
578
mcp-server/atomizer-tools/src/tools/canvas.ts
Normal file
578
mcp-server/atomizer-tools/src/tools/canvas.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Canvas Intent Processing Tools
|
||||
*
|
||||
* Tools for processing optimization workflow intents from the Canvas UI.
|
||||
* The canvas serializes node graphs to Intent JSON, which Claude interprets
|
||||
* using protocols and LAC to execute the optimization.
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { AtomizerTool } from "../index.js";
|
||||
import { PYTHON_PATH, STUDIES_DIR } from "../utils/paths.js";
|
||||
|
||||
// Intent type definitions matching frontend schema
|
||||
interface CanvasIntent {
|
||||
version: string;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a canvas intent and return detailed feedback
|
||||
*/
|
||||
function validateIntent(intent: CanvasIntent): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Model validation
|
||||
if (!intent.model?.path) {
|
||||
errors.push({
|
||||
field: "model.path",
|
||||
message: "Model file path is required",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// Solver validation
|
||||
if (!intent.solver?.type) {
|
||||
errors.push({
|
||||
field: "solver.type",
|
||||
message: "Solver type is required (e.g., SOL101)",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// Design variables validation
|
||||
if (!intent.design_variables || intent.design_variables.length === 0) {
|
||||
errors.push({
|
||||
field: "design_variables",
|
||||
message: "At least one design variable is required",
|
||||
severity: "error",
|
||||
});
|
||||
} else {
|
||||
intent.design_variables.forEach((dv, i) => {
|
||||
if (!dv.name) {
|
||||
errors.push({
|
||||
field: `design_variables[${i}].name`,
|
||||
message: "Design variable name is required",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (dv.min >= dv.max) {
|
||||
errors.push({
|
||||
field: `design_variables[${i}]`,
|
||||
message: `Invalid bounds: min (${dv.min}) must be less than max (${dv.max})`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Objectives validation
|
||||
if (!intent.objectives || intent.objectives.length === 0) {
|
||||
errors.push({
|
||||
field: "objectives",
|
||||
message: "At least one objective is required",
|
||||
severity: "error",
|
||||
});
|
||||
} else {
|
||||
intent.objectives.forEach((obj, i) => {
|
||||
if (!obj.name) {
|
||||
errors.push({
|
||||
field: `objectives[${i}].name`,
|
||||
message: "Objective name is required",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (!obj.extractor) {
|
||||
errors.push({
|
||||
field: `objectives[${i}].extractor`,
|
||||
message: "Objective must be connected to an extractor",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extractors validation
|
||||
if (!intent.extractors || intent.extractors.length === 0) {
|
||||
errors.push({
|
||||
field: "extractors",
|
||||
message: "At least one physics extractor is required",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
// Optimization settings
|
||||
if (!intent.optimization?.method) {
|
||||
errors.push({
|
||||
field: "optimization.method",
|
||||
message: "Optimization method not specified, will default to TPE",
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
if (!intent.optimization?.max_trials) {
|
||||
errors.push({
|
||||
field: "optimization.max_trials",
|
||||
message: "Max trials not specified, will default to 100",
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
// Multi-objective check
|
||||
if (intent.objectives && intent.objectives.length > 1) {
|
||||
if (intent.optimization?.method && intent.optimization.method !== "NSGA-II") {
|
||||
errors.push({
|
||||
field: "optimization.method",
|
||||
message: `Multiple objectives detected. Consider using NSGA-II instead of ${intent.optimization.method}`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert canvas intent to optimization_config.json format
|
||||
*/
|
||||
function intentToConfig(intent: CanvasIntent, studyName: string): Record<string, unknown> {
|
||||
// Map extractor IDs to physics names
|
||||
const extractorPhysicsMap: Record<string, string> = {
|
||||
E1: "displacement",
|
||||
E2: "frequency",
|
||||
E3: "stress",
|
||||
E4: "mass_bdf",
|
||||
E5: "mass_cad",
|
||||
E8: "zernike_op2",
|
||||
E9: "zernike_csv",
|
||||
E10: "zernike_rms",
|
||||
};
|
||||
|
||||
return {
|
||||
study_name: studyName,
|
||||
model: {
|
||||
path: intent.model.path,
|
||||
type: intent.model.type || "sim",
|
||||
},
|
||||
solver: {
|
||||
type: "nastran",
|
||||
solution: parseInt(intent.solver.type?.replace("SOL", "") || "101"),
|
||||
},
|
||||
design_variables: intent.design_variables.map((dv) => ({
|
||||
name: dv.name,
|
||||
expression_name: dv.name,
|
||||
lower: dv.min,
|
||||
upper: dv.max,
|
||||
type: "continuous",
|
||||
})),
|
||||
objectives: intent.objectives.map((obj) => ({
|
||||
name: obj.name,
|
||||
direction: obj.direction,
|
||||
weight: obj.weight || 1.0,
|
||||
extractor: obj.extractor,
|
||||
physics: extractorPhysicsMap[obj.extractor] || "custom",
|
||||
})),
|
||||
constraints: intent.constraints.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.operator === "<=" || c.operator === "<" ? "upper" : "lower",
|
||||
value: c.value,
|
||||
extractor: c.extractor,
|
||||
})),
|
||||
method: intent.optimization.method || "TPE",
|
||||
max_trials: intent.optimization.max_trials || 100,
|
||||
surrogate: intent.surrogate?.enabled
|
||||
? {
|
||||
type: intent.surrogate.type || "MLP",
|
||||
min_trials: intent.surrogate.min_trials || 20,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const canvasTools: AtomizerTool[] = [
|
||||
{
|
||||
definition: {
|
||||
name: "validate_canvas_intent",
|
||||
description:
|
||||
"Validate a canvas-generated optimization intent. Returns validation errors and warnings without creating a study.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
intent: {
|
||||
type: "object",
|
||||
description: "The optimization intent JSON from the canvas",
|
||||
},
|
||||
},
|
||||
required: ["intent"],
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const intent = args.intent as CanvasIntent;
|
||||
|
||||
if (!intent) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
valid: false,
|
||||
errors: [{ field: "intent", message: "Intent is required", severity: "error" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const errors = validateIntent(intent);
|
||||
const hasErrors = errors.some((e) => e.severity === "error");
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
valid: !hasErrors,
|
||||
errors: errors.filter((e) => e.severity === "error"),
|
||||
warnings: errors.filter((e) => e.severity === "warning"),
|
||||
summary: hasErrors
|
||||
? `Found ${errors.filter((e) => e.severity === "error").length} error(s) that must be fixed`
|
||||
: `Intent is valid with ${errors.filter((e) => e.severity === "warning").length} warning(s)`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
definition: {
|
||||
name: "execute_canvas_intent",
|
||||
description:
|
||||
"Execute a canvas-generated optimization intent. Creates a study from the intent and optionally starts the optimization.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
intent: {
|
||||
type: "object",
|
||||
description: "The optimization intent JSON from the canvas",
|
||||
},
|
||||
study_name: {
|
||||
type: "string",
|
||||
description: "Name for the study (snake_case)",
|
||||
},
|
||||
auto_run: {
|
||||
type: "boolean",
|
||||
description: "Whether to automatically start the optimization after creating the study",
|
||||
},
|
||||
},
|
||||
required: ["intent", "study_name"],
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const intent = args.intent as CanvasIntent;
|
||||
const studyName = args.study_name as string;
|
||||
const autoRun = args.auto_run as boolean || false;
|
||||
|
||||
// First validate
|
||||
const errors = validateIntent(intent);
|
||||
const hasErrors = errors.some((e) => e.severity === "error");
|
||||
|
||||
if (hasErrors) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
action: "validation_failed",
|
||||
errors: errors.filter((e) => e.severity === "error"),
|
||||
message: "Cannot execute intent - validation errors must be fixed first",
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert intent to config
|
||||
const config = intentToConfig(intent, studyName);
|
||||
const configJson = JSON.stringify(config).replace(/"/g, '\\"');
|
||||
|
||||
// Python script to create study from config
|
||||
const script = `
|
||||
import sys
|
||||
import json
|
||||
sys.path.insert(0, r"C:/Users/antoi/Atomizer")
|
||||
from pathlib import Path
|
||||
from optimization_engine.study.creator import StudyCreator
|
||||
|
||||
config = json.loads("""${configJson}""")
|
||||
study_name = "${studyName}"
|
||||
|
||||
try:
|
||||
creator = StudyCreator()
|
||||
result = creator.create_from_config(study_name, config)
|
||||
print(json.dumps({"success": True, "study_name": study_name, "path": str(result)}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
sys.exit(1)
|
||||
`;
|
||||
|
||||
try {
|
||||
const output = execSync(`"${PYTHON_PATH}" -c "${script}"`, {
|
||||
encoding: "utf-8",
|
||||
cwd: STUDIES_DIR,
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const result = JSON.parse(output.trim());
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
action: "creation_failed",
|
||||
error: result.error,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If auto_run, start the optimization
|
||||
if (autoRun) {
|
||||
const runScript = `
|
||||
import sys
|
||||
sys.path.insert(0, r"C:/Users/antoi/Atomizer")
|
||||
from optimization_engine.core.runner import OptimizationRunner
|
||||
|
||||
try:
|
||||
runner = OptimizationRunner("${studyName}")
|
||||
runner.start_async()
|
||||
print("STARTED")
|
||||
except Exception as e:
|
||||
print(f"RUN_ERROR: {e}")
|
||||
`;
|
||||
try {
|
||||
const runOutput = execSync(`"${PYTHON_PATH}" -c "${runScript}"`, {
|
||||
encoding: "utf-8",
|
||||
cwd: STUDIES_DIR,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: "created_and_started",
|
||||
study_name: studyName,
|
||||
path: result.path,
|
||||
message: `Study "${studyName}" created and optimization started!`,
|
||||
config_summary: {
|
||||
design_variables: intent.design_variables.length,
|
||||
objectives: intent.objectives.length,
|
||||
constraints: intent.constraints.length,
|
||||
method: intent.optimization.method || "TPE",
|
||||
max_trials: intent.optimization.max_trials || 100,
|
||||
},
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (runError) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: "created_but_run_failed",
|
||||
study_name: studyName,
|
||||
path: result.path,
|
||||
run_error: runError instanceof Error ? runError.message : String(runError),
|
||||
message: `Study created but failed to start optimization. You can start it manually.`,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action: "created",
|
||||
study_name: studyName,
|
||||
path: result.path,
|
||||
message: `Study "${studyName}" created successfully! Use run_optimization to start.`,
|
||||
config_summary: {
|
||||
design_variables: intent.design_variables.length,
|
||||
objectives: intent.objectives.length,
|
||||
constraints: intent.constraints.length,
|
||||
method: intent.optimization.method || "TPE",
|
||||
max_trials: intent.optimization.max_trials || 100,
|
||||
},
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
action: "error",
|
||||
error: message,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
definition: {
|
||||
name: "interpret_canvas_intent",
|
||||
description:
|
||||
"Interpret a canvas intent and provide recommendations. Does not create anything - just analyzes and suggests improvements.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
intent: {
|
||||
type: "object",
|
||||
description: "The optimization intent JSON from the canvas",
|
||||
},
|
||||
},
|
||||
required: ["intent"],
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const intent = args.intent as CanvasIntent;
|
||||
|
||||
const analysis: Record<string, unknown> = {
|
||||
source: intent.source,
|
||||
timestamp: intent.timestamp,
|
||||
};
|
||||
|
||||
// Analyze problem characteristics
|
||||
const numObjectives = intent.objectives?.length || 0;
|
||||
const numDesignVars = intent.design_variables?.length || 0;
|
||||
const numConstraints = intent.constraints?.length || 0;
|
||||
|
||||
analysis.problem_type = numObjectives > 1 ? "multi-objective" : "single-objective";
|
||||
analysis.complexity = numDesignVars > 5 ? "high" : numDesignVars > 2 ? "medium" : "low";
|
||||
|
||||
// Method recommendation based on problem characteristics
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (numObjectives > 1 && intent.optimization?.method !== "NSGA-II") {
|
||||
recommendations.push(
|
||||
`Consider using NSGA-II for multi-objective optimization (${numObjectives} objectives detected)`
|
||||
);
|
||||
}
|
||||
|
||||
if (numDesignVars > 10 && intent.optimization?.method === "CMA-ES") {
|
||||
recommendations.push(
|
||||
"CMA-ES may struggle with high-dimensional problems. Consider TPE or GP-BO."
|
||||
);
|
||||
}
|
||||
|
||||
if ((intent.optimization?.max_trials || 100) < 50 && numDesignVars > 5) {
|
||||
recommendations.push(
|
||||
`Trial budget (${intent.optimization?.max_trials || 100}) may be insufficient for ${numDesignVars} design variables. Consider 100+ trials.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!intent.surrogate?.enabled && (intent.optimization?.max_trials || 100) > 100) {
|
||||
recommendations.push(
|
||||
"Consider enabling neural surrogate for faster optimization with high trial counts."
|
||||
);
|
||||
}
|
||||
|
||||
analysis.recommendations = recommendations;
|
||||
analysis.suggested_method =
|
||||
numObjectives > 1
|
||||
? "NSGA-II"
|
||||
: numDesignVars > 10
|
||||
? "TPE"
|
||||
: "TPE"; // Default to TPE for most cases
|
||||
|
||||
analysis.suggested_trials =
|
||||
numDesignVars <= 3 ? 50 : numDesignVars <= 6 ? 100 : numDesignVars <= 10 ? 200 : 500;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(analysis, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user