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:
@@ -3,10 +3,14 @@
|
||||
*
|
||||
* This component uses useSpecStore instead of the legacy useCanvasStore.
|
||||
* It renders type-specific configuration forms based on the selected node.
|
||||
*
|
||||
* For custom extractors, integrates CodeEditorPanel with Claude AI generation.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Microscope, Trash2, X, AlertCircle } from 'lucide-react';
|
||||
import { Microscope, Trash2, X, AlertCircle, Code, FileCode } from 'lucide-react';
|
||||
import { CodeEditorPanel } from './CodeEditorPanel';
|
||||
import { generateExtractorCode, validateExtractorCode, streamExtractorCode, checkCodeDependencies, testExtractorCode } from '../../../lib/api/claude';
|
||||
import {
|
||||
useSpecStore,
|
||||
useSpec,
|
||||
@@ -507,7 +511,51 @@ interface ExtractorNodeConfigProps {
|
||||
onChange: (field: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
// Default template for custom extractors
|
||||
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
|
||||
Custom Extractor Function
|
||||
|
||||
This function is called after FEA simulation completes.
|
||||
It receives the results and should return extracted values.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Extract physics from FEA results.
|
||||
|
||||
Args:
|
||||
op2_path: Path to OP2 results file
|
||||
fem_path: Path to FEM file
|
||||
params: Current design variable values
|
||||
subcase_id: Subcase ID to analyze
|
||||
|
||||
Returns:
|
||||
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
|
||||
"""
|
||||
# Load OP2 results
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
# Example: Extract max displacement
|
||||
if subcase_id in op2.displacements:
|
||||
disp = op2.displacements[subcase_id]
|
||||
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,
|
||||
}
|
||||
`;
|
||||
|
||||
function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
const [showCodeEditor, setShowCodeEditor] = useState(false);
|
||||
const studyId = useSpecStore(state => state.studyId);
|
||||
|
||||
const extractorOptions = [
|
||||
{ id: 'E1', name: 'Displacement', type: 'displacement' },
|
||||
{ id: 'E2', name: 'Frequency', type: 'frequency' },
|
||||
@@ -519,6 +567,78 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
{ id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
|
||||
];
|
||||
|
||||
// Check if this is a custom function type
|
||||
// Using string comparison to handle both 'custom_function' and potential legacy 'custom' values
|
||||
const isCustomType = node.type === 'custom_function' || (node.type as string) === 'custom';
|
||||
|
||||
// Get current source code
|
||||
const currentCode = node.function?.source_code || DEFAULT_EXTRACTOR_TEMPLATE;
|
||||
|
||||
// Handle Claude generation request (non-streaming fallback)
|
||||
const handleRequestGeneration = useCallback(async (prompt: string): Promise<string> => {
|
||||
const response = await generateExtractorCode({
|
||||
prompt,
|
||||
study_id: studyId || undefined,
|
||||
existing_code: node.function?.source_code,
|
||||
output_names: node.outputs?.map(o => o.name) || [],
|
||||
});
|
||||
return response.code;
|
||||
}, [studyId, node.function?.source_code, node.outputs]);
|
||||
|
||||
// Handle streaming generation (preferred)
|
||||
const handleStreamingGeneration = useCallback((
|
||||
request: { prompt: string; study_id?: string; existing_code?: string; output_names?: string[] },
|
||||
callbacks: { onToken: (t: string) => void; onComplete: (c: string, o: string[]) => void; onError: (e: string) => void }
|
||||
) => {
|
||||
return streamExtractorCode(request, callbacks);
|
||||
}, []);
|
||||
|
||||
// Handle code change from editor
|
||||
const handleCodeChange = useCallback((code: string) => {
|
||||
onChange('function', {
|
||||
...node.function,
|
||||
name: node.function?.name || 'custom_extract',
|
||||
source_code: code,
|
||||
});
|
||||
}, [node.function, onChange]);
|
||||
|
||||
// Handle code validation (includes syntax check and dependency check)
|
||||
const handleValidateCode = useCallback(async (code: string) => {
|
||||
// First check syntax
|
||||
const syntaxResult = await validateExtractorCode(code);
|
||||
if (!syntaxResult.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: syntaxResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Then check dependencies
|
||||
const depResult = await checkCodeDependencies(code);
|
||||
|
||||
// Build combined result
|
||||
const warnings: string[] = [...depResult.warnings];
|
||||
if (depResult.missing.length > 0) {
|
||||
warnings.push(`Missing packages: ${depResult.missing.join(', ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: warnings.length > 0 ? `Warnings: ${warnings.join('; ')}` : undefined,
|
||||
outputs: depResult.imports.length > 0 ? { imports: depResult.imports.join(', ') } : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle live testing against OP2 file
|
||||
const handleTestCode = useCallback(async (code: string) => {
|
||||
const result = await testExtractorCode({
|
||||
code,
|
||||
study_id: studyId || undefined,
|
||||
subcase_id: 1,
|
||||
});
|
||||
return result;
|
||||
}, [studyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@@ -544,23 +664,73 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
{opt.id} - {opt.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Custom Function</option>
|
||||
<option value="custom_function">Custom Function</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{node.type === 'custom_function' && node.function && (
|
||||
<div>
|
||||
<label className={labelClass}>Custom Function</label>
|
||||
<input
|
||||
type="text"
|
||||
value={node.function.name || ''}
|
||||
readOnly
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Edit custom code in dedicated editor.</p>
|
||||
{/* Custom Code Editor Button */}
|
||||
{isCustomType && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCodeEditor(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
|
||||
bg-violet-500/20 hover:bg-violet-500/30 border border-violet-500/30
|
||||
rounded-lg text-violet-400 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Code size={16} />
|
||||
Edit Custom Code
|
||||
</button>
|
||||
|
||||
{node.function?.source_code && (
|
||||
<div className="text-xs text-dark-500 flex items-center gap-1.5">
|
||||
<FileCode size={12} />
|
||||
Custom code defined ({node.function.source_code.split('\n').length} lines)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{showCodeEditor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode size={18} className="text-violet-400" />
|
||||
<span className="font-medium text-white">Custom Extractor: {node.name}</span>
|
||||
<span className="text-xs text-dark-500 bg-dark-800 px-2 py-0.5 rounded">.py</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCodeEditor(false)}
|
||||
className="p-1.5 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="flex-1">
|
||||
<CodeEditorPanel
|
||||
initialCode={currentCode}
|
||||
extractorName={node.name}
|
||||
outputs={node.outputs?.map(o => o.name) || []}
|
||||
onChange={handleCodeChange}
|
||||
onRequestGeneration={handleRequestGeneration}
|
||||
onRequestStreamingGeneration={handleStreamingGeneration}
|
||||
onRun={handleValidateCode}
|
||||
onTest={handleTestCode}
|
||||
onClose={() => setShowCodeEditor(false)}
|
||||
showHeader={false}
|
||||
height="100%"
|
||||
studyId={studyId || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<label className={labelClass}>Outputs</label>
|
||||
<input
|
||||
@@ -570,7 +740,11 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||
placeholder="value, unit"
|
||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Outputs are defined by extractor type.</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{isCustomType
|
||||
? 'Detected from return statement in code.'
|
||||
: 'Outputs are defined by extractor type.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user