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:
@@ -3,3 +3,27 @@ export { useCanvasStore } from './useCanvasStore';
|
||||
export type { OptimizationConfig } from './useCanvasStore';
|
||||
export { useCanvasChat } from './useCanvasChat';
|
||||
export { useIntentParser } from './useIntentParser';
|
||||
|
||||
// Spec Store (AtomizerSpec v2.0)
|
||||
export {
|
||||
useSpecStore,
|
||||
useSpec,
|
||||
useSpecLoading,
|
||||
useSpecError,
|
||||
useSpecValidation,
|
||||
useSelectedNodeId,
|
||||
useSelectedEdgeId,
|
||||
useSpecHash,
|
||||
useSpecIsDirty,
|
||||
useDesignVariables,
|
||||
useExtractors,
|
||||
useObjectives,
|
||||
useConstraints,
|
||||
useCanvasEdges,
|
||||
useSelectedNode,
|
||||
} from './useSpecStore';
|
||||
|
||||
// WebSocket Sync
|
||||
export { useSpecWebSocket } from './useSpecWebSocket';
|
||||
export type { ConnectionStatus } from './useSpecWebSocket';
|
||||
export { ConnectionStatusIndicator } from '../components/canvas/ConnectionStatusIndicator';
|
||||
|
||||
@@ -11,12 +11,25 @@ export interface CanvasState {
|
||||
studyPath?: string;
|
||||
}
|
||||
|
||||
export interface CanvasModification {
|
||||
action: 'add_node' | 'update_node' | 'remove_node' | 'add_edge' | 'remove_edge';
|
||||
nodeType?: string;
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
data?: Record<string, any>;
|
||||
source?: string;
|
||||
target?: string;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface UseChatOptions {
|
||||
studyId?: string | null;
|
||||
mode?: ChatMode;
|
||||
useWebSocket?: boolean;
|
||||
canvasState?: CanvasState | null;
|
||||
onError?: (error: string) => void;
|
||||
onCanvasModification?: (modification: CanvasModification) => void;
|
||||
onSpecUpdated?: (spec: any) => void; // Called when Claude modifies the spec
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
@@ -35,6 +48,8 @@ export function useChat({
|
||||
useWebSocket = true,
|
||||
canvasState: initialCanvasState,
|
||||
onError,
|
||||
onCanvasModification,
|
||||
onSpecUpdated,
|
||||
}: UseChatOptions = {}) {
|
||||
const [state, setState] = useState<ChatState>({
|
||||
messages: [],
|
||||
@@ -49,6 +64,23 @@ export function useChat({
|
||||
// Track canvas state for sending with messages
|
||||
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
|
||||
|
||||
// Sync mode prop changes to internal state (triggers WebSocket reconnect)
|
||||
useEffect(() => {
|
||||
if (mode !== state.mode) {
|
||||
console.log(`[useChat] Mode prop changed from ${state.mode} to ${mode}, triggering reconnect`);
|
||||
// Close existing WebSocket
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
// Update internal state to trigger reconnect
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
mode,
|
||||
sessionId: null,
|
||||
isConnected: false,
|
||||
}));
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
@@ -82,9 +114,16 @@ export function useChat({
|
||||
const data = await response.json();
|
||||
setState((prev) => ({ ...prev, sessionId: data.session_id }));
|
||||
|
||||
// Connect WebSocket
|
||||
// Connect WebSocket - use backend directly in dev mode
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/claude/sessions/${data.session_id}/ws`;
|
||||
// Use port 8001 to match start-dashboard.bat
|
||||
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
|
||||
// Both modes use the same WebSocket - mode is handled by session config
|
||||
// Power mode uses --dangerously-skip-permissions in CLI
|
||||
// User mode uses --allowedTools to restrict access
|
||||
const wsPath = `/api/claude/sessions/${data.session_id}/ws`;
|
||||
const wsUrl = `${protocol}//${backendHost}${wsPath}`;
|
||||
console.log(`[useChat] Connecting to WebSocket (${state.mode} mode): ${wsUrl}`);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -126,6 +165,9 @@ export function useChat({
|
||||
|
||||
// Handle WebSocket messages
|
||||
const handleWebSocketMessage = useCallback((data: any) => {
|
||||
// Debug: log all incoming WebSocket messages
|
||||
console.log('[useChat] WebSocket message received:', data.type, data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'text':
|
||||
currentMessageRef.current += data.content || '';
|
||||
@@ -212,11 +254,51 @@ export function useChat({
|
||||
// Canvas state was updated - could show notification
|
||||
break;
|
||||
|
||||
case 'canvas_modification':
|
||||
// Assistant wants to modify the canvas (from MCP tools in user mode)
|
||||
console.log('[useChat] Received canvas_modification:', data.modification);
|
||||
if (onCanvasModification && data.modification) {
|
||||
console.log('[useChat] Calling onCanvasModification callback');
|
||||
onCanvasModification(data.modification);
|
||||
} else {
|
||||
console.warn('[useChat] canvas_modification received but no handler or modification:', {
|
||||
hasCallback: !!onCanvasModification,
|
||||
modification: data.modification
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spec_updated':
|
||||
// Assistant modified the spec - we receive the full updated spec
|
||||
console.log('[useChat] Spec updated by assistant:', data.tool, data.reason);
|
||||
if (onSpecUpdated && data.spec) {
|
||||
// Directly update the canvas with the new spec
|
||||
onSpecUpdated(data.spec);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spec_modified':
|
||||
// Legacy: Assistant modified the spec directly (from power mode write tools)
|
||||
console.log('[useChat] Spec was modified by assistant (legacy):', data.tool, data.changes);
|
||||
// Treat this as a canvas modification to trigger reload
|
||||
if (onCanvasModification) {
|
||||
// Create a synthetic modification event to trigger canvas refresh
|
||||
onCanvasModification({
|
||||
action: 'add_node', // Use add_node as it triggers refresh
|
||||
data: {
|
||||
_refresh: true,
|
||||
tool: data.tool,
|
||||
changes: data.changes,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response - ignore
|
||||
break;
|
||||
}
|
||||
}, [onError]);
|
||||
}, [onError, onCanvasModification]);
|
||||
|
||||
// Switch mode (requires new session)
|
||||
const switchMode = useCallback(async (newMode: ChatMode) => {
|
||||
@@ -462,6 +544,18 @@ export function useChat({
|
||||
}
|
||||
}, [useWebSocket]);
|
||||
|
||||
// Notify backend when user edits canvas (so Claude sees the changes)
|
||||
const notifyCanvasEdit = useCallback((spec: any) => {
|
||||
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'canvas_edit',
|
||||
spec: spec,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [useWebSocket]);
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
isThinking: state.isThinking,
|
||||
@@ -475,5 +569,6 @@ export function useChat({
|
||||
cancelRequest,
|
||||
switchMode,
|
||||
updateCanvasState,
|
||||
notifyCanvasEdit,
|
||||
};
|
||||
}
|
||||
|
||||
349
atomizer-dashboard/frontend/src/hooks/useClaudeCode.ts
Normal file
349
atomizer-dashboard/frontend/src/hooks/useClaudeCode.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Hook for Claude Code CLI integration
|
||||
*
|
||||
* Connects to backend that spawns actual Claude Code CLI processes.
|
||||
* This gives full power: file editing, command execution, etc.
|
||||
*
|
||||
* Unlike useChat (which uses MCP tools), this hook:
|
||||
* - Spawns actual Claude Code CLI in the backend
|
||||
* - Has full file system access
|
||||
* - Can edit files directly (not just return instructions)
|
||||
* - Uses Opus 4.5 model
|
||||
* - Has all Claude Code capabilities
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Message } from '../components/chat/ChatMessage';
|
||||
import { useCanvasStore } from './useCanvasStore';
|
||||
|
||||
export interface CanvasState {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
studyName?: string;
|
||||
studyPath?: string;
|
||||
}
|
||||
|
||||
interface UseClaudeCodeOptions {
|
||||
studyId?: string | null;
|
||||
canvasState?: CanvasState | null;
|
||||
onError?: (error: string) => void;
|
||||
onCanvasRefresh?: (studyId: string) => void;
|
||||
}
|
||||
|
||||
interface ClaudeCodeState {
|
||||
messages: Message[];
|
||||
isThinking: boolean;
|
||||
error: string | null;
|
||||
sessionId: string | null;
|
||||
isConnected: boolean;
|
||||
workingDir: string | null;
|
||||
}
|
||||
|
||||
export function useClaudeCode({
|
||||
studyId,
|
||||
canvasState: initialCanvasState,
|
||||
onError,
|
||||
onCanvasRefresh,
|
||||
}: UseClaudeCodeOptions = {}) {
|
||||
const [state, setState] = useState<ClaudeCodeState>({
|
||||
messages: [],
|
||||
isThinking: false,
|
||||
error: null,
|
||||
sessionId: null,
|
||||
isConnected: false,
|
||||
workingDir: null,
|
||||
});
|
||||
|
||||
// Track canvas state for sending with messages
|
||||
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const currentMessageRef = useRef<string>('');
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
|
||||
// Keep canvas state in sync with prop changes
|
||||
useEffect(() => {
|
||||
if (initialCanvasState) {
|
||||
canvasStateRef.current = initialCanvasState;
|
||||
}
|
||||
}, [initialCanvasState]);
|
||||
|
||||
// Get canvas store for auto-refresh
|
||||
const { loadFromConfig } = useCanvasStore();
|
||||
|
||||
// Connect to Claude Code WebSocket
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// In development, connect directly to backend (bypass Vite proxy for WebSockets)
|
||||
// Use port 8001 to match start-dashboard.bat
|
||||
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
|
||||
|
||||
// Use study-specific endpoint if studyId provided
|
||||
const wsUrl = studyId
|
||||
? `${protocol}//${backendHost}/api/claude-code/ws/${encodeURIComponent(studyId)}`
|
||||
: `${protocol}//${backendHost}/api/claude-code/ws`;
|
||||
|
||||
console.log('[ClaudeCode] Connecting to:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[ClaudeCode] Connected');
|
||||
setState((prev) => ({ ...prev, isConnected: true, error: null }));
|
||||
reconnectAttempts.current = 0;
|
||||
|
||||
// If no studyId in URL, send init message
|
||||
if (!studyId) {
|
||||
ws.send(JSON.stringify({ type: 'init', study_id: null }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[ClaudeCode] Disconnected');
|
||||
setState((prev) => ({ ...prev, isConnected: false }));
|
||||
|
||||
// Attempt reconnection
|
||||
if (reconnectAttempts.current < maxReconnectAttempts) {
|
||||
reconnectAttempts.current++;
|
||||
console.log(`[ClaudeCode] Reconnecting... attempt ${reconnectAttempts.current}`);
|
||||
setTimeout(connect, 2000 * reconnectAttempts.current);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
console.error('[ClaudeCode] WebSocket error:', event);
|
||||
setState((prev) => ({ ...prev, isConnected: false }));
|
||||
onError?.('Claude Code connection error');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[ClaudeCode] Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection on unmount
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [studyId]);
|
||||
|
||||
// Handle WebSocket messages
|
||||
const handleWebSocketMessage = useCallback(
|
||||
(data: any) => {
|
||||
switch (data.type) {
|
||||
case 'initialized':
|
||||
console.log('[ClaudeCode] Session initialized:', data.session_id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sessionId: data.session_id,
|
||||
workingDir: data.working_dir || null,
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
currentMessageRef.current += data.content || '';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.map((msg, idx) =>
|
||||
idx === prev.messages.length - 1 && msg.role === 'assistant'
|
||||
? { ...msg, content: currentMessageRef.current }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isThinking: false,
|
||||
messages: prev.messages.map((msg, idx) =>
|
||||
idx === prev.messages.length - 1 && msg.role === 'assistant'
|
||||
? { ...msg, isStreaming: false }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
currentMessageRef.current = '';
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[ClaudeCode] Error:', data.content);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isThinking: false,
|
||||
error: data.content || 'Unknown error',
|
||||
}));
|
||||
onError?.(data.content || 'Unknown error');
|
||||
currentMessageRef.current = '';
|
||||
break;
|
||||
|
||||
case 'refresh_canvas':
|
||||
// Claude made file changes - trigger canvas refresh
|
||||
console.log('[ClaudeCode] Canvas refresh requested:', data.reason);
|
||||
if (data.study_id) {
|
||||
onCanvasRefresh?.(data.study_id);
|
||||
reloadCanvasFromStudy(data.study_id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'canvas_updated':
|
||||
console.log('[ClaudeCode] Canvas state updated');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[ClaudeCode] Unknown message type:', data.type);
|
||||
}
|
||||
},
|
||||
[onError, onCanvasRefresh]
|
||||
);
|
||||
|
||||
// Reload canvas from study config
|
||||
const reloadCanvasFromStudy = useCallback(
|
||||
async (studyIdToReload: string) => {
|
||||
try {
|
||||
console.log('[ClaudeCode] Reloading canvas for study:', studyIdToReload);
|
||||
|
||||
// Fetch fresh config from backend
|
||||
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyIdToReload)}/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const config = data.config; // API returns { config: ..., path: ..., study_id: ... }
|
||||
|
||||
// Reload canvas with new config
|
||||
loadFromConfig(config);
|
||||
|
||||
// Add system message about refresh
|
||||
const refreshMessage: Message = {
|
||||
id: `msg_${Date.now()}_refresh`,
|
||||
role: 'system',
|
||||
content: `Canvas refreshed with latest changes from ${studyIdToReload}`,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, refreshMessage],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[ClaudeCode] Failed to reload canvas:', error);
|
||||
}
|
||||
},
|
||||
[loadFromConfig]
|
||||
);
|
||||
|
||||
const generateMessageId = () => {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || state.isThinking) return;
|
||||
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
onError?.('Not connected to Claude Code');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Add assistant message placeholder
|
||||
const assistantMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, userMessage, assistantMessage],
|
||||
isThinking: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// Reset current message tracking
|
||||
currentMessageRef.current = '';
|
||||
|
||||
// Send message via WebSocket with canvas state
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
content: content.trim(),
|
||||
canvas_state: canvasStateRef.current || undefined,
|
||||
})
|
||||
);
|
||||
},
|
||||
[state.isThinking, onError]
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: [],
|
||||
error: null,
|
||||
}));
|
||||
currentMessageRef.current = '';
|
||||
}, []);
|
||||
|
||||
// Update canvas state (call this when canvas changes)
|
||||
const updateCanvasState = useCallback((newCanvasState: CanvasState | null) => {
|
||||
canvasStateRef.current = newCanvasState;
|
||||
|
||||
// Also send to backend to update context
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'set_canvas',
|
||||
canvas_state: newCanvasState,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send ping to keep connection alive
|
||||
useEffect(() => {
|
||||
const pingInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
return () => clearInterval(pingInterval);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
isThinking: state.isThinking,
|
||||
error: state.error,
|
||||
sessionId: state.sessionId,
|
||||
isConnected: state.isConnected,
|
||||
workingDir: state.workingDir,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
updateCanvasState,
|
||||
reloadCanvasFromStudy,
|
||||
};
|
||||
}
|
||||
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;
|
||||
@@ -18,7 +18,8 @@ export const useOptimizationWebSocket = ({ studyId, onMessage }: UseOptimization
|
||||
const host = window.location.host; // This will be localhost:3000 in dev
|
||||
// If using proxy in vite.config.ts, this works.
|
||||
// If not, we might need to hardcode backend URL for dev:
|
||||
const backendHost = import.meta.env.DEV ? 'localhost:8000' : host;
|
||||
// Use port 8001 to match start-dashboard.bat
|
||||
const backendHost = import.meta.env.DEV ? 'localhost:8001' : host;
|
||||
|
||||
setSocketUrl(`${protocol}//${backendHost}/api/ws/optimization/${studyId}`);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user