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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
348
atomizer-dashboard/frontend/src/lib/api/claude.ts
Normal file
348
atomizer-dashboard/frontend/src/lib/api/claude.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user