feat(canvas): Claude Code integration with streaming, snippets, and live preview

Backend:
- Add POST /generate-extractor for AI code generation via Claude CLI
- Add POST /generate-extractor/stream for SSE streaming generation
- Add POST /validate-extractor with enhanced syntax checking
- Add POST /check-dependencies for import analysis
- Add POST /test-extractor for live OP2 file testing
- Add ClaudeCodeSession service for managing CLI sessions

Frontend:
- Add lib/api/claude.ts with typed API functions
- Enhance CodeEditorPanel with:
  - Streaming generation with live preview
  - Code snippets library (6 templates: displacement, stress, frequency, mass, energy, reaction)
  - Test button for live OP2 validation
  - Cancel button for stopping generation
  - Dependency warnings display
- Integrate streaming and testing into NodeConfigPanelV2

Uses Claude CLI (--print mode) to leverage Pro/Max subscription without API costs.
This commit is contained in:
2026-01-20 13:08:12 -05:00
parent ffd41e3a60
commit b05412f807
5 changed files with 2311 additions and 49 deletions

View File

@@ -5,11 +5,12 @@
* - Python syntax highlighting
* - Auto-completion for common patterns
* - Error display
* - Claude AI code generation button
* - Claude AI code generation with streaming support
* - Preview of extracted outputs
* - Code snippets library
*/
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
import {
Play,
@@ -23,19 +24,42 @@ import {
ChevronRight,
FileCode,
Sparkles,
Square,
BookOpen,
FlaskConical,
} from 'lucide-react';
// Monaco editor types
type Monaco = Parameters<OnMount>[1];
type EditorInstance = Parameters<OnMount>[0];
/** Streaming generation callbacks */
export interface StreamingCallbacks {
onToken: (token: string) => void;
onComplete: (code: string, outputs: string[]) => void;
onError: (error: string) => void;
}
/** Request format for streaming generation */
export interface StreamingGenerationRequest {
prompt: string;
study_id?: string;
existing_code?: string;
output_names?: string[];
}
interface CodeEditorPanelProps {
/** Initial code content */
initialCode?: string;
/** Callback when code changes */
onChange?: (code: string) => void;
/** Callback when user requests Claude generation */
/** Callback when user requests Claude generation (non-streaming) */
onRequestGeneration?: (prompt: string) => Promise<string>;
/** Callback for streaming generation (preferred over onRequestGeneration) */
onRequestStreamingGeneration?: (
request: StreamingGenerationRequest,
callbacks: StreamingCallbacks
) => AbortController;
/** Whether the panel is read-only */
readOnly?: boolean;
/** Extractor name for context */
@@ -48,8 +72,12 @@ interface CodeEditorPanelProps {
showHeader?: boolean;
/** Callback when running code (validation) */
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
/** Callback for live testing against OP2 file */
onTest?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, number>; execution_time_ms?: number }>;
/** Close button callback */
onClose?: () => void;
/** Study ID for context in generation */
studyId?: string;
}
// Default Python template for custom extractors
@@ -103,30 +131,231 @@ def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) ->
}
`;
// Code snippets library
interface CodeSnippet {
id: string;
name: string;
category: string;
description: string;
code: string;
}
const CODE_SNIPPETS: CodeSnippet[] = [
{
id: 'displacement',
name: 'Max Displacement',
category: 'Displacement',
description: 'Extract maximum displacement magnitude from results',
code: `"""Extract maximum displacement magnitude"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
# Displacement data: [time, node, component] where component 1-3 are x,y,z
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {'max_displacement': max_disp}
`,
},
{
id: 'stress_vonmises',
name: 'Von Mises Stress',
category: 'Stress',
description: 'Extract maximum von Mises stress from shell elements',
code: `"""Extract maximum von Mises stress from shell elements"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
max_stress = 0.0
# Check CQUAD4 elements
if subcase_id in op2.cquad4_stress:
stress = op2.cquad4_stress[subcase_id]
# Von Mises is typically in the last column
vm_stress = stress.data[0, :, -1] # [time, element, component]
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
# Check CTRIA3 elements
if subcase_id in op2.ctria3_stress:
stress = op2.ctria3_stress[subcase_id]
vm_stress = stress.data[0, :, -1]
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
return {'max_vonmises': max_stress}
`,
},
{
id: 'frequency',
name: 'Natural Frequency',
category: 'Modal',
description: 'Extract first natural frequency from modal analysis',
code: `"""Extract natural frequencies from modal analysis"""
from pyNastran.op2.op2 import OP2
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
freq_1 = 0.0
freq_2 = 0.0
freq_3 = 0.0
if subcase_id in op2.eigenvalues:
eig = op2.eigenvalues[subcase_id]
freqs = eig.radians / (2 * 3.14159) # Convert to Hz
if len(freqs) >= 1:
freq_1 = float(freqs[0])
if len(freqs) >= 2:
freq_2 = float(freqs[1])
if len(freqs) >= 3:
freq_3 = float(freqs[2])
return {
'freq_1': freq_1,
'freq_2': freq_2,
'freq_3': freq_3,
}
`,
},
{
id: 'mass_grid',
name: 'Grid Point Mass',
category: 'Mass',
description: 'Extract total mass from grid point weight generator',
code: `"""Extract mass from grid point weight generator"""
from pyNastran.op2.op2 import OP2
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
total_mass = 0.0
if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight:
gpw = op2.grid_point_weight
# Mass is typically M[0,0] in the mass matrix
if hasattr(gpw, 'mass') and len(gpw.mass) > 0:
total_mass = float(gpw.mass[0])
return {'total_mass': total_mass}
`,
},
{
id: 'strain_energy',
name: 'Strain Energy',
category: 'Energy',
description: 'Extract total strain energy from elements',
code: `"""Extract strain energy from elements"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
total_energy = 0.0
# Sum strain energy from all element types
for key in dir(op2):
if 'strain_energy' in key.lower():
result = getattr(op2, key)
if isinstance(result, dict) and subcase_id in result:
se = result[subcase_id]
if hasattr(se, 'data'):
total_energy += float(np.sum(se.data))
return {'strain_energy': total_energy}
`,
},
{
id: 'reaction_force',
name: 'Reaction Forces',
category: 'Force',
description: 'Extract reaction forces at constrained nodes',
code: `"""Extract reaction forces at single point constraints"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
max_reaction = 0.0
total_reaction_z = 0.0
if subcase_id in op2.spc_forces:
spc = op2.spc_forces[subcase_id]
# SPC forces: [time, node, component] where 1-3 are Fx,Fy,Fz
forces = spc.data[0, :, 1:4] # Get Fx, Fy, Fz
magnitudes = np.sqrt(np.sum(forces**2, axis=1))
max_reaction = float(np.max(magnitudes))
total_reaction_z = float(np.sum(forces[:, 2])) # Sum of Fz
return {
'max_reaction': max_reaction,
'total_reaction_z': total_reaction_z,
}
`,
},
];
export function CodeEditorPanel({
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
onChange,
onRequestGeneration,
onRequestStreamingGeneration,
readOnly = false,
extractorName = 'custom_extractor',
outputs = [],
height = 400,
showHeader = true,
onRun,
onTest,
onClose,
studyId,
}: CodeEditorPanelProps) {
const [code, setCode] = useState(initialCode);
const [streamingCode, setStreamingCode] = useState(''); // Partial code during streaming
const [isGenerating, setIsGenerating] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | null>(null);
const [testResult, setTestResult] = useState<{ success: boolean; outputs?: Record<string, number>; execution_time_ms?: number } | null>(null);
const [copied, setCopied] = useState(false);
const [showPromptInput, setShowPromptInput] = useState(false);
const [generationPrompt, setGenerationPrompt] = useState('');
const [showOutputs, setShowOutputs] = useState(true);
const [showSnippets, setShowSnippets] = useState(false);
const editorRef = useRef<EditorInstance | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
// Handle editor mount
const handleEditorMount: OnMount = (editor, monaco) => {
@@ -222,25 +451,65 @@ export function CodeEditorPanel({
setTimeout(() => setCopied(false), 2000);
}, [code]);
// Request Claude generation
// Request Claude generation (with streaming support)
const handleGenerate = useCallback(async () => {
if (!onRequestGeneration || !generationPrompt.trim()) return;
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
setIsGenerating(true);
setError(null);
setStreamingCode('');
try {
const generatedCode = await onRequestGeneration(generationPrompt);
setCode(generatedCode);
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed');
} finally {
setIsGenerating(false);
// Prefer streaming if available
if (onRequestStreamingGeneration) {
abortControllerRef.current = onRequestStreamingGeneration(
{
prompt: generationPrompt,
study_id: studyId,
existing_code: code !== DEFAULT_EXTRACTOR_TEMPLATE ? code : undefined,
output_names: outputs,
},
{
onToken: (token) => {
setStreamingCode(prev => prev + token);
},
onComplete: (generatedCode, _outputs) => {
setCode(generatedCode);
setStreamingCode('');
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
setIsGenerating(false);
},
onError: (errorMsg) => {
setError(errorMsg);
setStreamingCode('');
setIsGenerating(false);
},
}
);
} else if (onRequestGeneration) {
// Fallback to non-streaming
try {
const generatedCode = await onRequestGeneration(generationPrompt);
setCode(generatedCode);
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed');
} finally {
setIsGenerating(false);
}
}
}, [onRequestGeneration, generationPrompt, onChange]);
}, [onRequestGeneration, onRequestStreamingGeneration, generationPrompt, onChange, code, outputs, studyId]);
// Cancel ongoing generation
const handleCancelGeneration = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsGenerating(false);
setStreamingCode('');
}, []);
// Run/validate code
const handleRun = useCallback(async () => {
@@ -262,6 +531,27 @@ export function CodeEditorPanel({
setIsRunning(false);
}
}, [code, onRun]);
// Test code against real OP2 file
const handleTest = useCallback(async () => {
if (!onTest) return;
setIsTesting(true);
setError(null);
setTestResult(null);
try {
const result = await onTest(code);
setTestResult(result);
if (!result.success && result.error) {
setError(result.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Test failed');
} finally {
setIsTesting(false);
}
}, [code, onTest]);
return (
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
@@ -274,11 +564,20 @@ export function CodeEditorPanel({
<span className="text-xs text-dark-500">.py</span>
</div>
<div className="flex items-center gap-2">
{/* Snippets Button */}
<button
onClick={() => setShowSnippets(!showSnippets)}
className={`p-1.5 rounded transition-colors ${showSnippets ? 'text-amber-400 bg-amber-500/20' : 'text-dark-400 hover:text-amber-400 hover:bg-amber-500/10'}`}
title="Code Snippets"
>
<BookOpen size={16} />
</button>
{/* Claude Generate Button */}
{onRequestGeneration && (
{(onRequestGeneration || onRequestStreamingGeneration) && (
<button
onClick={() => setShowPromptInput(!showPromptInput)}
className="p-1.5 rounded text-violet-400 hover:bg-violet-500/20 transition-colors"
className={`p-1.5 rounded transition-colors ${showPromptInput ? 'text-violet-400 bg-violet-500/20' : 'text-violet-400 hover:bg-violet-500/20'}`}
title="Generate with Claude"
>
<Sparkles size={16} />
@@ -298,9 +597,9 @@ export function CodeEditorPanel({
{onRun && (
<button
onClick={handleRun}
disabled={isRunning}
disabled={isRunning || isTesting}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
title="Validate code"
title="Validate code syntax"
>
{isRunning ? (
<RefreshCw size={16} className="animate-spin" />
@@ -309,6 +608,22 @@ export function CodeEditorPanel({
)}
</button>
)}
{/* Test Button - Live Preview */}
{onTest && (
<button
onClick={handleTest}
disabled={isRunning || isTesting}
className="p-1.5 rounded text-cyan-400 hover:bg-cyan-500/20 transition-colors disabled:opacity-50"
title="Test against real OP2 file"
>
{isTesting ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<FlaskConical size={16} />
)}
</button>
)}
{/* Close Button */}
{onClose && (
@@ -340,28 +655,82 @@ export function CodeEditorPanel({
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setShowPromptInput(false)}
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors"
disabled={isGenerating}
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors disabled:opacity-50"
>
Cancel
</button>
{isGenerating ? (
<button
onClick={handleCancelGeneration}
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded hover:bg-red-500 transition-colors flex items-center gap-1.5"
>
<Square size={12} />
Stop
</button>
) : (
<button
onClick={handleGenerate}
disabled={!generationPrompt.trim()}
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
<Sparkles size={12} />
Generate
</button>
)}
</div>
{/* Streaming Preview */}
{isGenerating && streamingCode && (
<div className="mt-3 p-3 bg-dark-900 rounded-lg border border-dark-600 max-h-48 overflow-auto">
<div className="flex items-center gap-2 mb-2">
<RefreshCw size={12} className="text-violet-400 animate-spin" />
<span className="text-xs text-violet-400">Generating code...</span>
</div>
<pre className="text-xs text-dark-300 font-mono whitespace-pre-wrap">{streamingCode}</pre>
</div>
)}
</div>
)}
{/* Code Snippets Panel */}
{showSnippets && (
<div className="px-4 py-3 border-b border-dark-700 bg-amber-500/5 max-h-64 overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-amber-400" />
<span className="text-xs text-amber-400 font-medium">Code Snippets</span>
</div>
<button
onClick={handleGenerate}
disabled={isGenerating || !generationPrompt.trim()}
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
onClick={() => setShowSnippets(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
>
{isGenerating ? (
<>
<RefreshCw size={12} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={12} />
Generate
</>
)}
<X size={14} />
</button>
</div>
<div className="space-y-2">
{CODE_SNIPPETS.map((snippet) => (
<button
key={snippet.id}
onClick={() => {
setCode(snippet.code);
onChange?.(snippet.code);
setShowSnippets(false);
}}
className="w-full text-left p-2 rounded-lg bg-dark-800 hover:bg-dark-700 border border-dark-600 hover:border-amber-500/30 transition-colors group"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-white group-hover:text-amber-400 transition-colors">
{snippet.name}
</span>
<span className="text-xs text-dark-500 bg-dark-700 px-1.5 py-0.5 rounded">
{snippet.category}
</span>
</div>
<p className="text-xs text-dark-400">{snippet.description}</p>
</button>
))}
</div>
</div>
)}
@@ -401,6 +770,32 @@ export function CodeEditorPanel({
/>
</div>
{/* Test Results Preview */}
{testResult && testResult.success && testResult.outputs && (
<div className="border-t border-dark-700 bg-cyan-500/5">
<div className="px-4 py-2 flex items-center gap-2 text-xs">
<FlaskConical size={12} className="text-cyan-400" />
<span className="text-cyan-400 font-medium">Live Test Results</span>
{testResult.execution_time_ms && (
<span className="ml-auto text-dark-500">
{testResult.execution_time_ms.toFixed(0)}ms
</span>
)}
</div>
<div className="px-4 pb-3 space-y-1">
{Object.entries(testResult.outputs).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-cyan-400 font-mono">{key}</span>
<span className="text-white font-medium">{typeof value === 'number' ? value.toFixed(6) : String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Outputs Preview */}
{(outputs.length > 0 || runResult?.outputs) && (
<div className="border-t border-dark-700">
@@ -409,7 +804,7 @@ export function CodeEditorPanel({
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-800/50 transition-colors"
>
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Outputs</span>
<span>Expected Outputs</span>
<span className="ml-auto text-dark-500">
{runResult?.outputs
? Object.keys(runResult.outputs).length