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