Files
Atomizer/atomizer-dashboard/frontend/src/hooks/useChat.ts
Anto01 ac5e9b4054 docs: Comprehensive documentation update for Dashboard V3 and Canvas
## Documentation Updates
- DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection
- DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE
- CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features
- CLAUDE.md: Added dashboard quick reference and Canvas V3 features

## Canvas V3 Features Documented
- File Browser: Browse studies directory for model files
- Model Introspection: Auto-discover expressions, solver type, dependencies
- One-Click Add: Add expressions as design variables instantly
- Claude Bug Fixes: WebSocket reconnection, SQL errors resolved
- Health Check: /api/health endpoint for monitoring

## Backend Services
- NX introspection service with expression discovery
- File browser API with type filtering
- Claude session management improvements
- Context builder enhancements

## Frontend Components
- FileBrowser: Modal for file selection with search
- IntrospectionPanel: View discovered model information
- ExpressionSelector: Dropdown for design variable configuration
- Improved chat hooks with reconnection logic

## Plan Documents
- Added RALPH_LOOP_CANVAS_V2/V3 implementation records
- Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN
- Added investigation and sync documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:48:58 -05:00

480 lines
13 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { Message } from '../components/chat/ChatMessage';
import { ToolCall } from '../components/chat/ToolCallCard';
export type ChatMode = 'user' | 'power';
export interface CanvasState {
nodes: any[];
edges: any[];
studyName?: string;
studyPath?: string;
}
interface UseChatOptions {
studyId?: string | null;
mode?: ChatMode;
useWebSocket?: boolean;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
}
interface ChatState {
messages: Message[];
isThinking: boolean;
error: string | null;
suggestions: string[];
sessionId: string | null;
mode: ChatMode;
isConnected: boolean;
}
export function useChat({
studyId,
mode = 'user',
useWebSocket = true,
canvasState: initialCanvasState,
onError,
}: UseChatOptions = {}) {
const [state, setState] = useState<ChatState>({
messages: [],
isThinking: false,
error: null,
suggestions: [],
sessionId: null,
mode,
isConnected: false,
});
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
const abortControllerRef = useRef<AbortController | null>(null);
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
const wsRef = useRef<WebSocket | null>(null);
const currentMessageRef = useRef<string>('');
const currentToolCallsRef = useRef<ToolCall[]>([]);
// Load suggestions when study changes
useEffect(() => {
loadSuggestions();
}, [studyId]);
// Create session and connect WebSocket
useEffect(() => {
if (!useWebSocket) return;
const createSession = async () => {
try {
const response = await fetch('/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: state.mode,
study_id: studyId || undefined,
}),
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const data = await response.json();
setState((prev) => ({ ...prev, sessionId: data.session_id }));
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/claude/sessions/${data.session_id}/ws`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setState((prev) => ({ ...prev, isConnected: true }));
};
ws.onclose = () => {
setState((prev) => ({ ...prev, isConnected: false }));
};
ws.onerror = () => {
setState((prev) => ({ ...prev, isConnected: false }));
onError?.('WebSocket connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
wsRef.current = ws;
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to create session';
onError?.(message);
}
};
createSession();
return () => {
wsRef.current?.close();
wsRef.current = null;
};
}, [useWebSocket, state.mode, studyId]);
// Handle WebSocket messages
const handleWebSocketMessage = useCallback((data: any) => {
switch (data.type) {
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 'tool_call':
const toolCall: ToolCall = {
name: data.tool?.name || 'unknown',
arguments: data.tool?.arguments,
};
currentToolCallsRef.current.push(toolCall);
setState((prev) => ({
...prev,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, toolCalls: [...currentToolCallsRef.current] }
: msg
),
}));
break;
case 'tool_result':
// Update the last tool call with its result
if (currentToolCallsRef.current.length > 0) {
const lastIndex = currentToolCallsRef.current.length - 1;
currentToolCallsRef.current[lastIndex] = {
...currentToolCallsRef.current[lastIndex],
result: data.result,
};
setState((prev) => ({
...prev,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, toolCalls: [...currentToolCallsRef.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
),
}));
// Update conversation history
conversationHistoryRef.current.push({
role: 'assistant',
content: currentMessageRef.current,
});
currentMessageRef.current = '';
currentToolCallsRef.current = [];
break;
case 'error':
setState((prev) => ({
...prev,
isThinking: false,
error: data.message || 'Unknown error',
}));
onError?.(data.message || 'Unknown error');
currentMessageRef.current = '';
currentToolCallsRef.current = [];
break;
case 'context_updated':
// Study context was updated - could show notification
break;
case 'canvas_updated':
// Canvas state was updated - could show notification
break;
case 'pong':
// Heartbeat response - ignore
break;
}
}, [onError]);
// Switch mode (requires new session)
const switchMode = useCallback(async (newMode: ChatMode) => {
if (newMode === state.mode) return;
// Close existing WebSocket
wsRef.current?.close();
// Update mode - useEffect will create new session
setState((prev) => ({
...prev,
mode: newMode,
sessionId: null,
isConnected: false,
}));
}, [state.mode]);
const loadSuggestions = async () => {
try {
const url = studyId
? `/api/claude/suggestions?study_id=${encodeURIComponent(studyId)}`
: '/api/claude/suggestions';
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
setState((prev) => ({ ...prev, suggestions: data.suggestions || [] }));
}
} catch {
// Silently fail - suggestions are optional
}
};
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;
// Add user message
const userMessage: Message = {
id: generateMessageId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage],
isThinking: true,
error: null,
}));
// Update conversation history
conversationHistoryRef.current.push({
role: 'user',
content: content.trim(),
});
// If using WebSocket and connected, send via WebSocket
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
// Create assistant message placeholder
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
toolCalls: [],
};
setState((prev) => ({
...prev,
messages: [...prev.messages, assistantMessage],
}));
// Reset current message/tool tracking
currentMessageRef.current = '';
currentToolCallsRef.current = [];
// Send message via WebSocket with canvas state
wsRef.current.send(
JSON.stringify({
type: 'message',
content: content.trim(),
canvas_state: canvasStateRef.current || undefined,
})
);
return;
}
// Fall back to HTTP streaming
// Cancel any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
// Use streaming endpoint
const response = await fetch('/api/claude/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: content.trim(),
study_id: studyId || undefined,
conversation_history: conversationHistoryRef.current.slice(0, -1), // Exclude current message
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Request failed: ${response.status}`);
}
// Create assistant message placeholder
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setState((prev) => ({
...prev,
messages: [...prev.messages, assistantMessage],
}));
// Read stream
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let fullContent = '';
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.token) {
fullContent += parsed.token;
// Update message content
setState((prev) => ({
...prev,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: fullContent }
: msg
),
}));
} else if (parsed.error) {
throw new Error(parsed.error);
}
} catch {
// Skip invalid JSON
}
}
}
}
}
// Mark streaming complete
setState((prev) => ({
...prev,
isThinking: false,
messages: prev.messages.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, isStreaming: false, content: fullContent }
: msg
),
}));
// Update conversation history with assistant response
conversationHistoryRef.current.push({
role: 'assistant',
content: fullContent,
});
} catch (error: any) {
if (error.name === 'AbortError') {
// Request was cancelled
return;
}
const errorMessage = error.message || 'Failed to send message';
setState((prev) => ({
...prev,
isThinking: false,
error: errorMessage,
}));
onError?.(errorMessage);
}
},
[studyId, state.isThinking, onError]
);
const clearMessages = useCallback(() => {
setState((prev) => ({
...prev,
messages: [],
error: null,
}));
conversationHistoryRef.current = [];
}, []);
const cancelRequest = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setState((prev) => ({
...prev,
isThinking: false,
}));
}
}, []);
// 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 (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'set_canvas',
canvas_state: newCanvasState,
})
);
}
}, [useWebSocket]);
return {
messages: state.messages,
isThinking: state.isThinking,
error: state.error,
suggestions: state.suggestions,
sessionId: state.sessionId,
mode: state.mode,
isConnected: state.isConnected,
sendMessage,
clearMessages,
cancelRequest,
switchMode,
updateCanvasState,
};
}