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.
845 lines
28 KiB
TypeScript
845 lines
28 KiB
TypeScript
/**
|
|
* CodeEditorPanel - Monaco editor for custom extractor Python code
|
|
*
|
|
* Features:
|
|
* - Python syntax highlighting
|
|
* - Auto-completion for common patterns
|
|
* - Error display
|
|
* - Claude AI code generation with streaming support
|
|
* - Preview of extracted outputs
|
|
* - Code snippets library
|
|
*/
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
|
|
import {
|
|
Play,
|
|
Wand2,
|
|
Copy,
|
|
Check,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
X,
|
|
ChevronDown,
|
|
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 (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 */
|
|
extractorName?: string;
|
|
/** Output variable names */
|
|
outputs?: string[];
|
|
/** Optional height (default: 300px) */
|
|
height?: number | string;
|
|
/** Show/hide header */
|
|
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
|
|
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
|
|
Custom Extractor Function
|
|
|
|
This function is called after FEA simulation completes.
|
|
It receives the results and should return extracted values.
|
|
|
|
Available inputs:
|
|
- op2_path: Path to the .op2 results file
|
|
- fem_path: Path to the .fem file
|
|
- params: Dict of current design variable values
|
|
- subcase_id: Current subcase being analyzed (optional)
|
|
|
|
Return a dict with your 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]
|
|
# Get magnitude of displacement vectors
|
|
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,
|
|
# Add more outputs as needed
|
|
}
|
|
`;
|
|
|
|
// 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) => {
|
|
editorRef.current = editor;
|
|
monacoRef.current = monaco;
|
|
|
|
// Configure Python language
|
|
monaco.languages.registerCompletionItemProvider('python', {
|
|
provideCompletionItems: (model: Parameters<typeof monaco.editor.createModel>[0], position: { lineNumber: number; column: number }) => {
|
|
const word = model.getWordUntilPosition(position);
|
|
const range = {
|
|
startLineNumber: position.lineNumber,
|
|
endLineNumber: position.lineNumber,
|
|
startColumn: word.startColumn,
|
|
endColumn: word.endColumn,
|
|
};
|
|
|
|
const suggestions = [
|
|
{
|
|
label: 'op2.read_op2',
|
|
kind: monaco.languages.CompletionItemKind.Method,
|
|
insertText: 'op2.read_op2(op2_path)',
|
|
documentation: 'Read OP2 results file',
|
|
range,
|
|
},
|
|
{
|
|
label: 'op2.displacements',
|
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
insertText: 'op2.displacements[subcase_id]',
|
|
documentation: 'Access displacement results for a subcase',
|
|
range,
|
|
},
|
|
{
|
|
label: 'op2.eigenvectors',
|
|
kind: monaco.languages.CompletionItemKind.Property,
|
|
insertText: 'op2.eigenvectors[subcase_id]',
|
|
documentation: 'Access eigenvector results for modal analysis',
|
|
range,
|
|
},
|
|
{
|
|
label: 'np.max',
|
|
kind: monaco.languages.CompletionItemKind.Function,
|
|
insertText: 'np.max(${1:array})',
|
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
documentation: 'Get maximum value from array',
|
|
range,
|
|
},
|
|
{
|
|
label: 'np.sqrt',
|
|
kind: monaco.languages.CompletionItemKind.Function,
|
|
insertText: 'np.sqrt(${1:array})',
|
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
documentation: 'Square root of array elements',
|
|
range,
|
|
},
|
|
{
|
|
label: 'extract_function',
|
|
kind: monaco.languages.CompletionItemKind.Snippet,
|
|
insertText: `def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
|
"""Extract physics from FEA results."""
|
|
op2 = OP2()
|
|
op2.read_op2(op2_path)
|
|
|
|
# Your extraction logic here
|
|
|
|
return {
|
|
'\${1:output_name}': \${2:value},
|
|
}`,
|
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
documentation: 'Insert a complete extract function template',
|
|
range,
|
|
},
|
|
];
|
|
|
|
return { suggestions };
|
|
},
|
|
});
|
|
};
|
|
|
|
// Handle code change
|
|
const handleCodeChange: OnChange = (value) => {
|
|
const newCode = value || '';
|
|
setCode(newCode);
|
|
setError(null);
|
|
setRunResult(null);
|
|
onChange?.(newCode);
|
|
};
|
|
|
|
// Copy code to clipboard
|
|
const handleCopy = useCallback(() => {
|
|
navigator.clipboard.writeText(code);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}, [code]);
|
|
|
|
// Request Claude generation (with streaming support)
|
|
const handleGenerate = useCallback(async () => {
|
|
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
|
|
|
|
setIsGenerating(true);
|
|
setError(null);
|
|
setStreamingCode('');
|
|
|
|
// 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, 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 () => {
|
|
if (!onRun) return;
|
|
|
|
setIsRunning(true);
|
|
setError(null);
|
|
setRunResult(null);
|
|
|
|
try {
|
|
const result = await onRun(code);
|
|
setRunResult(result);
|
|
if (!result.success && result.error) {
|
|
setError(result.error);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Validation failed');
|
|
} finally {
|
|
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">
|
|
{/* Header */}
|
|
{showHeader && (
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-dark-700">
|
|
<div className="flex items-center gap-2">
|
|
<FileCode size={16} className="text-emerald-400" />
|
|
<span className="font-medium text-white text-sm">{extractorName}</span>
|
|
<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 || onRequestStreamingGeneration) && (
|
|
<button
|
|
onClick={() => setShowPromptInput(!showPromptInput)}
|
|
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} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Copy Button */}
|
|
<button
|
|
onClick={handleCopy}
|
|
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
|
title="Copy code"
|
|
>
|
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
|
</button>
|
|
|
|
{/* Run Button */}
|
|
{onRun && (
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={isRunning || isTesting}
|
|
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
|
|
title="Validate code syntax"
|
|
>
|
|
{isRunning ? (
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
) : (
|
|
<Play size={16} />
|
|
)}
|
|
</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 && (
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Claude Prompt Input */}
|
|
{showPromptInput && (
|
|
<div className="px-4 py-3 border-b border-dark-700 bg-violet-500/5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Wand2 size={14} className="text-violet-400" />
|
|
<span className="text-xs text-violet-400 font-medium">Generate with Claude</span>
|
|
</div>
|
|
<textarea
|
|
value={generationPrompt}
|
|
onChange={(e) => setGenerationPrompt(e.target.value)}
|
|
placeholder="Describe what you want to extract... e.g., 'Extract maximum von Mises stress and total mass from the model'"
|
|
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 resize-none focus:outline-none focus:border-violet-500"
|
|
rows={2}
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<button
|
|
onClick={() => setShowPromptInput(false)}
|
|
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={() => setShowSnippets(false)}
|
|
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 flex items-center gap-2">
|
|
<AlertCircle size={14} className="text-red-400 flex-shrink-0" />
|
|
<span className="text-xs text-red-400 font-mono">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Monaco Editor */}
|
|
<div className="flex-1 min-h-0">
|
|
<Editor
|
|
height={height}
|
|
language="python"
|
|
theme="vs-dark"
|
|
value={code}
|
|
onChange={handleCodeChange}
|
|
onMount={handleEditorMount}
|
|
options={{
|
|
readOnly,
|
|
minimap: { enabled: false },
|
|
fontSize: 13,
|
|
lineNumbers: 'on',
|
|
scrollBeyondLastLine: false,
|
|
wordWrap: 'on',
|
|
automaticLayout: true,
|
|
tabSize: 4,
|
|
insertSpaces: true,
|
|
padding: { top: 8, bottom: 8 },
|
|
scrollbar: {
|
|
vertical: 'auto',
|
|
horizontal: 'auto',
|
|
},
|
|
}}
|
|
/>
|
|
</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">
|
|
<button
|
|
onClick={() => setShowOutputs(!showOutputs)}
|
|
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>Expected Outputs</span>
|
|
<span className="ml-auto text-dark-500">
|
|
{runResult?.outputs
|
|
? Object.keys(runResult.outputs).length
|
|
: outputs.length}
|
|
</span>
|
|
</button>
|
|
{showOutputs && (
|
|
<div className="px-4 pb-3 space-y-1">
|
|
{runResult?.outputs ? (
|
|
Object.entries(runResult.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-emerald-400 font-mono">{key}</span>
|
|
<span className="text-dark-300">{String(value)}</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
outputs.map((output) => (
|
|
<div
|
|
key={output}
|
|
className="flex items-center px-2 py-1 bg-dark-800 rounded text-xs"
|
|
>
|
|
<span className="text-dark-400 font-mono">{output}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CodeEditorPanel;
|