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

View File

@@ -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>
</>
);

View File

@@ -0,0 +1,348 @@
/**
* Claude Code API Functions
*
* Provides typed API functions for interacting with Claude Code CLI
* through the backend endpoints.
*/
const API_BASE = '/api/claude-code';
// ============================================================================
// Types
// ============================================================================
export interface ExtractorGenerationRequest {
/** Description of what the extractor should do */
prompt: string;
/** Optional study ID for context */
study_id?: string;
/** Existing code to improve/modify */
existing_code?: string;
/** Expected output variable names */
output_names?: string[];
}
export interface ExtractorGenerationResponse {
/** Generated Python code */
code: string;
/** Detected output variable names */
outputs: string[];
/** Optional brief explanation */
explanation?: string;
}
export interface CodeValidationRequest {
/** Python code to validate */
code: string;
}
export interface CodeValidationResponse {
/** Whether the code is valid */
valid: boolean;
/** Error message if invalid */
error?: string;
}
export interface DependencyCheckResponse {
/** All imports found in code */
imports: string[];
/** Imports that are available */
available: string[];
/** Imports that are missing */
missing: string[];
/** Warnings about potentially problematic imports */
warnings: string[];
}
export interface TestExtractorRequest {
/** Python code to test */
code: string;
/** Optional study ID for finding OP2 files */
study_id?: string;
/** Subcase ID to test against (default 1) */
subcase_id?: number;
}
export interface TestExtractorResponse {
/** Whether the test succeeded */
success: boolean;
/** Extracted output values */
outputs?: Record<string, number>;
/** Error message if failed */
error?: string;
/** Execution time in milliseconds */
execution_time_ms?: number;
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Generate Python extractor code using Claude Code CLI.
*
* @param request - Generation request with prompt and context
* @returns Promise with generated code and detected outputs
* @throws Error if generation fails
*
* @example
* ```typescript
* const result = await generateExtractorCode({
* prompt: "Extract maximum von Mises stress from solid elements",
* output_names: ["max_stress"],
* });
* console.log(result.code);
* ```
*/
export async function generateExtractorCode(
request: ExtractorGenerationRequest
): Promise<ExtractorGenerationResponse> {
const response = await fetch(`${API_BASE}/generate-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Generation failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
/**
* Validate Python extractor code syntax.
*
* @param code - Python code to validate
* @returns Promise with validation result
*
* @example
* ```typescript
* const result = await validateExtractorCode("def extract(): pass");
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export async function validateExtractorCode(
code: string
): Promise<CodeValidationResponse> {
const response = await fetch(`${API_BASE}/validate-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
// Even if HTTP fails, return as invalid
return {
valid: false,
error: `Validation request failed: HTTP ${response.status}`,
};
}
return response.json();
}
/**
* Check dependencies in Python code.
*
* @param code - Python code to analyze
* @returns Promise with dependency check results
*
* @example
* ```typescript
* const result = await checkCodeDependencies("import numpy as np");
* if (result.missing.length > 0) {
* console.warn("Missing:", result.missing);
* }
* ```
*/
export async function checkCodeDependencies(
code: string
): Promise<DependencyCheckResponse> {
const response = await fetch(`${API_BASE}/check-dependencies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
return {
imports: [],
available: [],
missing: [],
warnings: ['Dependency check failed'],
};
}
return response.json();
}
/**
* Test extractor code against a sample OP2 file.
*
* @param request - Test request with code and optional study context
* @returns Promise with test results
*
* @example
* ```typescript
* const result = await testExtractorCode({
* code: "def extract(...): ...",
* study_id: "bracket_v1",
* });
* if (result.success) {
* console.log("Outputs:", result.outputs);
* }
* ```
*/
export async function testExtractorCode(
request: TestExtractorRequest
): Promise<TestExtractorResponse> {
const response = await fetch(`${API_BASE}/test-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
return {
success: false,
error: `Test request failed: HTTP ${response.status}`,
};
}
return response.json();
}
/**
* Check if Claude Code CLI is available.
*
* @returns Promise with availability status
*/
export async function checkClaudeStatus(): Promise<{
available: boolean;
message: string;
}> {
try {
const response = await fetch('/api/claude/status');
if (!response.ok) {
return { available: false, message: 'Status check failed' };
}
return response.json();
} catch {
return { available: false, message: 'Cannot reach backend' };
}
}
// ============================================================================
// Streaming Generation
// ============================================================================
export interface StreamingGenerationCallbacks {
/** Called when a new token is received */
onToken?: (token: string) => void;
/** Called when generation is complete */
onComplete?: (code: string, outputs: string[]) => void;
/** Called when an error occurs */
onError?: (error: string) => void;
}
/**
* Stream Python extractor code generation using Server-Sent Events.
*
* This provides real-time feedback as Claude generates the code,
* showing tokens as they arrive.
*
* @param request - Generation request with prompt and context
* @param callbacks - Callbacks for streaming events
* @returns AbortController to cancel the stream
*
* @example
* ```typescript
* const controller = streamExtractorCode(
* { prompt: "Extract maximum stress" },
* {
* onToken: (token) => setPartialCode(prev => prev + token),
* onComplete: (code, outputs) => {
* setCode(code);
* setIsGenerating(false);
* },
* onError: (error) => setError(error),
* }
* );
*
* // To cancel:
* controller.abort();
* ```
*/
export function streamExtractorCode(
request: ExtractorGenerationRequest,
callbacks: StreamingGenerationCallbacks
): AbortController {
const controller = new AbortController();
(async () => {
try {
const response = await fetch(`${API_BASE}/generate-extractor/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: controller.signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Stream failed' }));
callbacks.onError?.(error.detail || `HTTP ${response.status}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError?.('No response body');
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'token') {
callbacks.onToken?.(data.content);
} else if (data.type === 'done') {
callbacks.onComplete?.(data.code, data.outputs);
} else if (data.type === 'error') {
callbacks.onError?.(data.message);
}
} catch {
// Ignore parse errors for incomplete JSON
}
}
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// User cancelled, don't report as error
return;
}
callbacks.onError?.(error instanceof Error ? error.message : 'Stream failed');
}
})();
return controller;
}