feat(dashboard): Enhanced chat, spec management, and Claude integration
Backend: - spec.py: New AtomizerSpec REST API endpoints - spec_manager.py: SpecManager service for unified config - interview_engine.py: Study creation interview logic - claude.py: Enhanced Claude API with context - optimization.py: Extended optimization endpoints - context_builder.py, session_manager.py: Improved services Frontend: - Chat components: Enhanced message rendering, tool call cards - Hooks: useClaudeCode, useSpecWebSocket, improved useChat - Pages: Updated Dashboard, Analysis, Insights, Setup, Home - Components: ParallelCoordinatesPlot, ParetoPlot improvements - App.tsx: Route updates for canvas/studio Infrastructure: - vite.config.ts: Build configuration updates - start/stop-dashboard.bat: Script improvements
This commit is contained in:
288
atomizer-dashboard/frontend/src/hooks/useSpecWebSocket.ts
Normal file
288
atomizer-dashboard/frontend/src/hooks/useSpecWebSocket.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* useSpecWebSocket - WebSocket connection for real-time spec sync
|
||||
*
|
||||
* Connects to the backend WebSocket endpoint for live spec updates.
|
||||
* Handles auto-reconnection, message parsing, and store updates.
|
||||
*
|
||||
* P2.11-P2.14: WebSocket sync implementation
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useSpecStore } from './useSpecStore';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
interface SpecWebSocketMessage {
|
||||
type: 'modification' | 'full_sync' | 'error' | 'ping';
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ModificationPayload {
|
||||
operation: 'set' | 'add' | 'remove';
|
||||
path: string;
|
||||
value?: unknown;
|
||||
modified_by: string;
|
||||
timestamp: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface UseSpecWebSocketOptions {
|
||||
/**
|
||||
* Enable auto-reconnect on disconnect (default: true)
|
||||
*/
|
||||
autoReconnect?: boolean;
|
||||
|
||||
/**
|
||||
* Reconnect delay in ms (default: 3000)
|
||||
*/
|
||||
reconnectDelay?: number;
|
||||
|
||||
/**
|
||||
* Max reconnect attempts (default: 10)
|
||||
*/
|
||||
maxReconnectAttempts?: number;
|
||||
|
||||
/**
|
||||
* Client identifier for tracking modifications (default: 'canvas')
|
||||
*/
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
interface UseSpecWebSocketReturn {
|
||||
/**
|
||||
* Current connection status
|
||||
*/
|
||||
status: ConnectionStatus;
|
||||
|
||||
/**
|
||||
* Manually disconnect
|
||||
*/
|
||||
disconnect: () => void;
|
||||
|
||||
/**
|
||||
* Manually reconnect
|
||||
*/
|
||||
reconnect: () => void;
|
||||
|
||||
/**
|
||||
* Send a message to the WebSocket (for future use)
|
||||
*/
|
||||
send: (message: SpecWebSocketMessage) => void;
|
||||
|
||||
/**
|
||||
* Last error message if any
|
||||
*/
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useSpecWebSocket(
|
||||
studyId: string | null,
|
||||
options: UseSpecWebSocketOptions = {}
|
||||
): UseSpecWebSocketReturn {
|
||||
const {
|
||||
autoReconnect = true,
|
||||
reconnectDelay = 3000,
|
||||
maxReconnectAttempts = 10,
|
||||
clientId = 'canvas',
|
||||
} = options;
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
// Get store actions
|
||||
const reloadSpec = useSpecStore((s) => s.reloadSpec);
|
||||
const setError = useSpecStore((s) => s.setError);
|
||||
|
||||
// Build WebSocket URL
|
||||
const getWsUrl = useCallback((id: string): string => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
return `${protocol}//${host}/api/studies/${encodeURIComponent(id)}/spec/sync?client_id=${clientId}`;
|
||||
}, [clientId]);
|
||||
|
||||
// Handle incoming messages
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
const message: SpecWebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'modification': {
|
||||
const payload = message.payload as ModificationPayload;
|
||||
|
||||
// Skip if this is our own modification
|
||||
if (payload.modified_by === clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload spec to get latest state
|
||||
// In a more sophisticated implementation, we could apply the patch locally
|
||||
reloadSpec().catch((err) => {
|
||||
console.error('Failed to reload spec after modification:', err);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'full_sync': {
|
||||
// Full spec sync requested (e.g., after reconnect)
|
||||
reloadSpec().catch((err) => {
|
||||
console.error('Failed to reload spec during full_sync:', err);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const payload = message.payload as ErrorPayload;
|
||||
console.error('WebSocket error:', payload.message);
|
||||
setLastError(payload.message);
|
||||
setError(payload.message);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
// Keep-alive ping, respond with pong
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'pong' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unknown WebSocket message type:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
}, [clientId, reloadSpec, setError]);
|
||||
|
||||
// Connect to WebSocket
|
||||
const connect = useCallback(() => {
|
||||
if (!studyId) return;
|
||||
|
||||
// Clean up existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
setStatus('connecting');
|
||||
setLastError(null);
|
||||
|
||||
const url = getWsUrl(studyId);
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onerror = (event) => {
|
||||
console.error('WebSocket error:', event);
|
||||
setLastError('WebSocket connection error');
|
||||
};
|
||||
|
||||
ws.onclose = (_event) => {
|
||||
setStatus('disconnected');
|
||||
|
||||
// Check if we should reconnect
|
||||
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
setStatus('reconnecting');
|
||||
|
||||
// Clear any existing reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Schedule reconnect with exponential backoff
|
||||
const delay = reconnectDelay * Math.min(reconnectAttemptsRef.current, 5);
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||
setLastError('Max reconnection attempts reached');
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [studyId, getWsUrl, handleMessage, autoReconnect, reconnectDelay, maxReconnectAttempts]);
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(() => {
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
|
||||
setStatus('disconnected');
|
||||
}, [maxReconnectAttempts]);
|
||||
|
||||
// Reconnect
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
// Send message
|
||||
const send = useCallback((message: SpecWebSocketMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('WebSocket not connected, cannot send message');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Connect when studyId changes
|
||||
useEffect(() => {
|
||||
if (studyId) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount or studyId change
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [studyId, connect, disconnect]);
|
||||
|
||||
return {
|
||||
status,
|
||||
disconnect,
|
||||
reconnect,
|
||||
send,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSpecWebSocket;
|
||||
Reference in New Issue
Block a user