mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
// ========================================
|
|
// useWebSocket Hook
|
|
// ========================================
|
|
// Typed WebSocket connection management with auto-reconnect
|
|
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
import { useNotificationStore } from '@/stores';
|
|
import { useExecutionStore } from '@/stores/executionStore';
|
|
import { useFlowStore } from '@/stores';
|
|
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
|
import {
|
|
handleSessionLockedMessage,
|
|
handleSessionUnlockedMessage,
|
|
} from '@/stores/sessionManagerStore';
|
|
import {
|
|
useExecutionMonitorStore,
|
|
type ExecutionWSMessage,
|
|
} from '@/stores/executionMonitorStore';
|
|
import {
|
|
OrchestratorMessageSchema,
|
|
type OrchestratorWebSocketMessage,
|
|
type ExecutionLog,
|
|
} from '../types/execution';
|
|
import { SurfaceUpdateSchema } from '../packages/a2ui-runtime/core/A2UITypes';
|
|
import type { ToolCallKind, ToolCallExecution } from '../types/toolCall';
|
|
|
|
// Constants
|
|
const RECONNECT_DELAY_BASE = 1000; // 1 second
|
|
const RECONNECT_DELAY_MAX = 30000; // 30 seconds
|
|
const RECONNECT_DELAY_MULTIPLIER = 1.5;
|
|
|
|
// Access store state/actions via getState() - avoids calling hooks in callbacks/effects
|
|
// This is the zustand-recommended pattern for non-rendering store access
|
|
function getStoreState() {
|
|
const notification = useNotificationStore.getState();
|
|
const execution = useExecutionStore.getState();
|
|
const flow = useFlowStore.getState();
|
|
const cliSessions = useCliSessionStore.getState();
|
|
return {
|
|
// Notification store
|
|
setWsStatus: notification.setWsStatus,
|
|
setWsLastMessage: notification.setWsLastMessage,
|
|
incrementReconnectAttempts: notification.incrementReconnectAttempts,
|
|
resetReconnectAttempts: notification.resetReconnectAttempts,
|
|
addA2UINotification: notification.addA2UINotification,
|
|
// Execution store
|
|
setExecutionStatus: execution.setExecutionStatus,
|
|
setNodeStarted: execution.setNodeStarted,
|
|
setNodeCompleted: execution.setNodeCompleted,
|
|
setNodeFailed: execution.setNodeFailed,
|
|
addLog: execution.addLog,
|
|
completeExecution: execution.completeExecution,
|
|
currentExecution: execution.currentExecution,
|
|
// Tool call actions
|
|
startToolCall: execution.startToolCall,
|
|
updateToolCall: execution.updateToolCall,
|
|
completeToolCall: execution.completeToolCall,
|
|
toggleToolCallExpanded: execution.toggleToolCallExpanded,
|
|
// Tool call getters
|
|
getToolCallsForNode: execution.getToolCallsForNode,
|
|
// Node output actions
|
|
addNodeOutput: execution.addNodeOutput,
|
|
// Flow store
|
|
updateNode: flow.updateNode,
|
|
|
|
// CLI session store (PTY-backed terminal)
|
|
upsertCliSession: cliSessions.upsertSession,
|
|
removeCliSession: cliSessions.removeSession,
|
|
appendCliSessionOutput: cliSessions.appendOutput,
|
|
updateCliSessionPausedState: cliSessions.updateSessionPausedState,
|
|
};
|
|
}
|
|
|
|
export interface UseWebSocketOptions {
|
|
enabled?: boolean;
|
|
onMessage?: (message: OrchestratorWebSocketMessage) => void;
|
|
}
|
|
|
|
export interface UseWebSocketReturn {
|
|
isConnected: boolean;
|
|
send: (message: unknown) => void;
|
|
reconnect: () => void;
|
|
}
|
|
|
|
// ========== Tool Call Parsing Helpers ==========
|
|
|
|
/**
|
|
* Parse tool call metadata from content
|
|
* Expected format: "[Tool] toolName(args)"
|
|
*/
|
|
function parseToolCallMetadata(content: string): { toolName: string; args: string } | null {
|
|
// Handle string content
|
|
if (typeof content === 'string') {
|
|
const match = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
|
|
if (match) {
|
|
return { toolName: match[1], args: match[2] || '' };
|
|
}
|
|
}
|
|
|
|
// Handle object content with toolName field
|
|
try {
|
|
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
|
|
if (parsed && typeof parsed === 'object' && 'toolName' in parsed) {
|
|
return {
|
|
toolName: String(parsed.toolName),
|
|
args: parsed.parameters ? JSON.stringify(parsed.parameters) : '',
|
|
};
|
|
}
|
|
} catch {
|
|
// Not valid JSON, return null
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Infer tool call kind from tool name
|
|
*/
|
|
function inferToolCallKind(toolName: string): ToolCallKind {
|
|
const name = toolName.toLowerCase();
|
|
|
|
if (name === 'exec_command' || name === 'execute') return 'execute';
|
|
if (name === 'apply_patch' || name === 'patch') return 'patch';
|
|
if (name === 'web_search' || name === 'exa_search') return 'web_search';
|
|
if (name.startsWith('mcp_') || name.includes('mcp')) return 'mcp_tool';
|
|
if (name.includes('file') || name.includes('read') || name.includes('write')) return 'file_operation';
|
|
if (name.includes('think') || name.includes('reason')) return 'thinking';
|
|
|
|
// Default to execute
|
|
return 'execute';
|
|
}
|
|
|
|
/**
|
|
* Generate unique tool call ID
|
|
*/
|
|
function generateToolCallId(): string {
|
|
return `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
|
|
const { enabled = true, onMessage } = options;
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const reconnectDelayRef = useRef(RECONNECT_DELAY_BASE);
|
|
const mountedRef = useRef(true);
|
|
|
|
// Handle incoming WebSocket messages
|
|
const handleMessage = useCallback(
|
|
(event: MessageEvent) => {
|
|
// Guard against state updates after unmount
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
const stores = getStoreState();
|
|
|
|
// Store last message for debugging
|
|
stores.setWsLastMessage(data);
|
|
|
|
// Handle CLI messages
|
|
if (data.type?.startsWith('CLI_')) {
|
|
switch (data.type) {
|
|
// ========== PTY CLI Sessions ==========
|
|
case 'CLI_SESSION_CREATED': {
|
|
const session = data.payload?.session;
|
|
if (session?.sessionKey) {
|
|
stores.upsertCliSession(session);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_OUTPUT': {
|
|
const { sessionKey, data: chunk } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string' && typeof chunk === 'string') {
|
|
stores.appendCliSessionOutput(sessionKey, chunk);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_CLOSED': {
|
|
const { sessionKey } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string') {
|
|
stores.removeCliSession(sessionKey);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_PAUSED': {
|
|
const { sessionKey } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string') {
|
|
stores.updateCliSessionPausedState(sessionKey, true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_RESUMED': {
|
|
const { sessionKey } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string') {
|
|
stores.updateCliSessionPausedState(sessionKey, false);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_LOCKED': {
|
|
const { sessionKey, reason, executionId, timestamp } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string') {
|
|
handleSessionLockedMessage({
|
|
sessionKey,
|
|
reason: reason ?? 'Workflow execution',
|
|
executionId,
|
|
timestamp: timestamp ?? new Date().toISOString(),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_SESSION_UNLOCKED': {
|
|
const { sessionKey, timestamp } = data.payload ?? {};
|
|
if (typeof sessionKey === 'string') {
|
|
handleSessionUnlockedMessage({
|
|
sessionKey,
|
|
timestamp: timestamp ?? new Date().toISOString(),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'CLI_OUTPUT': {
|
|
const { chunkType, data: outputData, unit } = data.payload;
|
|
|
|
// Handle structured output
|
|
const unitContent = unit?.content || outputData;
|
|
const unitType = unit?.type || chunkType;
|
|
|
|
// Convert content to string for display
|
|
let content: string;
|
|
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
|
|
// Format tool_call display
|
|
content = JSON.stringify(unitContent);
|
|
} else {
|
|
content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent);
|
|
}
|
|
|
|
// ========== Tool Call Processing ==========
|
|
// Parse and start new tool call if this is a tool_call type
|
|
if (unitType === 'tool_call') {
|
|
const metadata = parseToolCallMetadata(content);
|
|
if (metadata) {
|
|
const callId = generateToolCallId();
|
|
const currentNodeId = stores.currentExecution?.currentNodeId;
|
|
|
|
if (currentNodeId) {
|
|
stores.startToolCall(currentNodeId, callId, {
|
|
kind: inferToolCallKind(metadata.toolName),
|
|
description: metadata.args
|
|
? `${metadata.toolName}(${metadata.args})`
|
|
: metadata.toolName,
|
|
});
|
|
|
|
// Also add to node output for streaming display
|
|
stores.addNodeOutput(currentNodeId, {
|
|
type: 'tool_call',
|
|
content,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== Stream Processing ==========
|
|
// Update tool call output buffer if we have an active tool call for this node
|
|
const currentNodeId = stores.currentExecution?.currentNodeId;
|
|
if (currentNodeId && (unitType === 'stdout' || unitType === 'stderr')) {
|
|
const toolCalls = stores.getToolCallsForNode?.(currentNodeId);
|
|
const activeCall = toolCalls?.find((c: ToolCallExecution) => c.status === 'executing');
|
|
|
|
if (activeCall) {
|
|
stores.updateToolCall(currentNodeId, activeCall.callId, {
|
|
outputChunk: content,
|
|
stream: unitType === 'stderr' ? 'stderr' : 'stdout',
|
|
});
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle EXECUTION messages (from orchestrator execution-in-session)
|
|
if (data.type?.startsWith('EXECUTION_')) {
|
|
const handleExecutionMessage = useExecutionMonitorStore.getState().handleExecutionMessage;
|
|
handleExecutionMessage(data as ExecutionWSMessage);
|
|
return;
|
|
}
|
|
|
|
// Handle A2UI surface messages
|
|
if (data.type === 'a2ui-surface') {
|
|
const parsed = SurfaceUpdateSchema.safeParse(data.payload);
|
|
if (parsed.success) {
|
|
stores.addA2UINotification(parsed.data, 'Interactive UI');
|
|
} else {
|
|
console.warn('[WebSocket] Invalid A2UI surface:', parsed.error.issues);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if this is an orchestrator message
|
|
if (!data.type?.startsWith('ORCHESTRATOR_')) {
|
|
return;
|
|
}
|
|
|
|
// Validate message with zod schema
|
|
const parsed = OrchestratorMessageSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
console.warn('[WebSocket] Invalid orchestrator message:', parsed.error.issues);
|
|
return;
|
|
}
|
|
|
|
// Cast validated data to our TypeScript interface
|
|
const message = parsed.data as OrchestratorWebSocketMessage;
|
|
|
|
// Only process messages for current execution
|
|
const { currentExecution } = stores;
|
|
if (currentExecution && message.execId !== currentExecution.execId) {
|
|
return;
|
|
}
|
|
|
|
// Dispatch to execution store based on message type
|
|
switch (message.type) {
|
|
case 'ORCHESTRATOR_STATE_UPDATE':
|
|
stores.setExecutionStatus(message.status, message.currentNodeId);
|
|
// Check for completion
|
|
if (message.status === 'completed' || message.status === 'failed') {
|
|
stores.completeExecution(message.status);
|
|
}
|
|
break;
|
|
|
|
case 'ORCHESTRATOR_NODE_STARTED':
|
|
stores.setNodeStarted(message.nodeId);
|
|
// Update canvas node status
|
|
stores.updateNode(message.nodeId, { executionStatus: 'running' });
|
|
break;
|
|
|
|
case 'ORCHESTRATOR_NODE_COMPLETED':
|
|
stores.setNodeCompleted(message.nodeId, message.result);
|
|
// Update canvas node status
|
|
stores.updateNode(message.nodeId, {
|
|
executionStatus: 'completed',
|
|
executionResult: message.result,
|
|
});
|
|
break;
|
|
|
|
case 'ORCHESTRATOR_NODE_FAILED':
|
|
stores.setNodeFailed(message.nodeId, message.error);
|
|
// Update canvas node status
|
|
stores.updateNode(message.nodeId, {
|
|
executionStatus: 'failed',
|
|
executionError: message.error,
|
|
});
|
|
break;
|
|
|
|
case 'ORCHESTRATOR_LOG':
|
|
stores.addLog(message.log as ExecutionLog);
|
|
break;
|
|
}
|
|
|
|
// Call custom message handler if provided
|
|
onMessage?.(message);
|
|
} catch (error) {
|
|
console.error('[WebSocket] Failed to parse message:', error);
|
|
}
|
|
},
|
|
[onMessage] // Only dependency is onMessage, store access via getState()
|
|
);
|
|
|
|
// Connect to WebSocket
|
|
// Use ref to avoid circular dependency with scheduleReconnect
|
|
const connectRef = useRef<(() => void) | null>(null);
|
|
|
|
// Schedule reconnection with exponential backoff
|
|
// Define this first to avoid circular dependency
|
|
const scheduleReconnect = useCallback(() => {
|
|
// Don't reconnect after unmount
|
|
if (!mountedRef.current) return;
|
|
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
|
|
const delay = reconnectDelayRef.current;
|
|
console.log(`[WebSocket] Reconnecting in ${delay}ms...`);
|
|
|
|
const stores = getStoreState();
|
|
stores.setWsStatus('reconnecting');
|
|
stores.incrementReconnectAttempts();
|
|
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
connectRef.current?.();
|
|
}, delay);
|
|
|
|
// Increase delay for next attempt (exponential backoff)
|
|
reconnectDelayRef.current = Math.min(
|
|
reconnectDelayRef.current * RECONNECT_DELAY_MULTIPLIER,
|
|
RECONNECT_DELAY_MAX
|
|
);
|
|
}, []); // No dependencies - uses connectRef and getStoreState()
|
|
|
|
const connect = useCallback(() => {
|
|
if (!enabled || !mountedRef.current) return;
|
|
|
|
// Close existing connection to avoid orphaned sockets
|
|
if (wsRef.current) {
|
|
wsRef.current.onclose = null; // Prevent onclose from triggering reconnect
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
|
|
// Construct WebSocket URL
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
|
|
try {
|
|
getStoreState().setWsStatus('connecting');
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
console.log('[WebSocket] Connected');
|
|
const s = getStoreState();
|
|
s.setWsStatus('connected');
|
|
s.resetReconnectAttempts();
|
|
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
|
|
|
// Request any pending questions from backend
|
|
ws.send(JSON.stringify({
|
|
type: 'FRONTEND_READY',
|
|
payload: { action: 'requestPendingQuestions' }
|
|
}));
|
|
};
|
|
|
|
ws.onmessage = handleMessage;
|
|
|
|
ws.onclose = () => {
|
|
console.log('[WebSocket] Disconnected');
|
|
getStoreState().setWsStatus('disconnected');
|
|
wsRef.current = null;
|
|
scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
console.warn('[WebSocket] Connection error');
|
|
getStoreState().setWsStatus('error');
|
|
};
|
|
} catch (error) {
|
|
console.error('[WebSocket] Failed to connect:', error);
|
|
getStoreState().setWsStatus('error');
|
|
scheduleReconnect();
|
|
}
|
|
}, [enabled, handleMessage, scheduleReconnect]);
|
|
|
|
// Update connect ref after connect is defined
|
|
connectRef.current = connect;
|
|
|
|
// Send message through WebSocket
|
|
const send = useCallback((message: unknown) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify(message));
|
|
} else {
|
|
console.warn('[WebSocket] Cannot send message: not connected');
|
|
}
|
|
}, []);
|
|
|
|
// Manual reconnect
|
|
// Use connectRef to avoid depending on connect
|
|
const reconnect = useCallback(() => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
|
connectRef.current?.();
|
|
}, []); // No dependencies - uses connectRef
|
|
|
|
// Check connection status
|
|
const isConnected = wsRef.current?.readyState === WebSocket.OPEN;
|
|
|
|
// Connect on mount, cleanup on unmount
|
|
useEffect(() => {
|
|
// Reset mounted flag (needed after React Strict Mode remount)
|
|
mountedRef.current = true;
|
|
|
|
if (enabled) {
|
|
connect();
|
|
}
|
|
|
|
// Listen for A2UI action events and send via WebSocket
|
|
const handleA2UIAction = (event: CustomEvent) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify(event.detail));
|
|
} else {
|
|
console.warn('[WebSocket] Cannot send A2UI action: not connected');
|
|
}
|
|
};
|
|
|
|
// Type the event listener properly
|
|
window.addEventListener('a2ui-action', handleA2UIAction as EventListener);
|
|
|
|
return () => {
|
|
// Mark as unmounted to prevent state updates in handleMessage
|
|
mountedRef.current = false;
|
|
|
|
window.removeEventListener('a2ui-action', handleA2UIAction as EventListener);
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.onclose = null; // Prevent onclose from triggering orphaned reconnect
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
};
|
|
}, [enabled, connect]);
|
|
|
|
return {
|
|
isConnected,
|
|
send,
|
|
reconnect,
|
|
};
|
|
}
|
|
|
|
export default useWebSocket;
|