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

@@ -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;
}