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:
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