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:
@@ -30,6 +30,7 @@ function App() {
|
||||
|
||||
{/* Canvas page - full screen, no sidebar */}
|
||||
<Route path="canvas" element={<CanvasView />} />
|
||||
<Route path="canvas/*" element={<CanvasView />} />
|
||||
|
||||
{/* Study pages - with sidebar layout */}
|
||||
<Route element={<MainLayout />}>
|
||||
|
||||
@@ -26,8 +26,8 @@ interface DesignVariable {
|
||||
name: string;
|
||||
parameter?: string; // Optional: the actual parameter name if different from name
|
||||
unit?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface Constraint {
|
||||
|
||||
@@ -8,14 +8,15 @@ import { ScatterChart, Scatter, Line, XAxis, YAxis, CartesianGrid, Tooltip, Cell
|
||||
|
||||
interface ParetoTrial {
|
||||
trial_number: number;
|
||||
values: [number, number];
|
||||
values: number[]; // Support variable number of objectives
|
||||
params: Record<string, number>;
|
||||
constraint_satisfied?: boolean;
|
||||
}
|
||||
|
||||
interface Objective {
|
||||
name: string;
|
||||
type: 'minimize' | 'maximize';
|
||||
type?: 'minimize' | 'maximize';
|
||||
direction?: 'minimize' | 'maximize'; // Alternative field used by some configs
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Main Canvas Component
|
||||
export { AtomizerCanvas } from './AtomizerCanvas';
|
||||
export { SpecRenderer } from './SpecRenderer';
|
||||
|
||||
// Palette
|
||||
export { NodePalette } from './palette/NodePalette';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ToolCallCard, ToolCall } from './ToolCallCard';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
isStreaming?: boolean;
|
||||
@@ -18,6 +18,18 @@ interface ChatMessageProps {
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isSystem = message.role === 'system';
|
||||
|
||||
// System messages are displayed centered with special styling
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center my-2">
|
||||
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
@@ -13,8 +13,10 @@ import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ThinkingIndicator } from './ThinkingIndicator';
|
||||
import { ModeToggle } from './ModeToggle';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
import { useChat, CanvasState, CanvasModification } from '../../hooks/useChat';
|
||||
import { useStudy } from '../../context/StudyContext';
|
||||
import { useCanvasStore } from '../../hooks/useCanvasStore';
|
||||
import { NodeType } from '../../lib/canvas/schema';
|
||||
|
||||
interface ChatPaneProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +33,76 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Get canvas state and modification functions from the store
|
||||
const { nodes, edges, addNode, updateNodeData, selectNode, deleteSelected } = useCanvasStore();
|
||||
|
||||
// Build canvas state for chat context
|
||||
const canvasState: CanvasState | null = useMemo(() => {
|
||||
if (nodes.length === 0) return null;
|
||||
return {
|
||||
nodes: nodes.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
data: n.data,
|
||||
position: n.position,
|
||||
})),
|
||||
edges: edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
})),
|
||||
studyName: selectedStudy?.name || selectedStudy?.id,
|
||||
};
|
||||
}, [nodes, edges, selectedStudy]);
|
||||
|
||||
// Track position offset for multiple node additions
|
||||
const nodeAddCountRef = useRef(0);
|
||||
|
||||
// Handle canvas modifications from the assistant
|
||||
const handleCanvasModification = React.useCallback((modification: CanvasModification) => {
|
||||
console.log('Canvas modification from assistant:', modification);
|
||||
|
||||
switch (modification.action) {
|
||||
case 'add_node':
|
||||
if (modification.nodeType) {
|
||||
const nodeType = modification.nodeType as NodeType;
|
||||
// Calculate position: offset each new node so they don't stack
|
||||
const basePosition = modification.position || { x: 100, y: 100 };
|
||||
const offset = nodeAddCountRef.current * 120;
|
||||
const position = {
|
||||
x: basePosition.x,
|
||||
y: basePosition.y + offset,
|
||||
};
|
||||
nodeAddCountRef.current += 1;
|
||||
// Reset counter after a delay (for batch operations)
|
||||
setTimeout(() => { nodeAddCountRef.current = 0; }, 2000);
|
||||
|
||||
addNode(nodeType, position, modification.data);
|
||||
console.log(`Added ${nodeType} node at position:`, position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_node':
|
||||
if (modification.nodeId && modification.data) {
|
||||
updateNodeData(modification.nodeId, modification.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'remove_node':
|
||||
if (modification.nodeId) {
|
||||
selectNode(modification.nodeId);
|
||||
deleteSelected();
|
||||
}
|
||||
break;
|
||||
|
||||
// Edge operations would need additional store methods
|
||||
case 'add_edge':
|
||||
case 'remove_edge':
|
||||
console.warn('Edge modification not yet implemented:', modification);
|
||||
break;
|
||||
}
|
||||
}, [addNode, updateNodeData, selectNode, deleteSelected]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
@@ -41,22 +113,38 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
switchMode,
|
||||
updateCanvasState,
|
||||
} = useChat({
|
||||
studyId: selectedStudy?.id,
|
||||
mode: 'user',
|
||||
useWebSocket: true,
|
||||
canvasState,
|
||||
onError: (err) => console.error('Chat error:', err),
|
||||
onCanvasModification: handleCanvasModification,
|
||||
});
|
||||
|
||||
// Keep canvas state synced with chat
|
||||
useEffect(() => {
|
||||
updateCanvasState(canvasState);
|
||||
}, [canvasState, updateCanvasState]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isThinking]);
|
||||
|
||||
// Welcome message based on study context
|
||||
const welcomeMessage = selectedStudy
|
||||
? `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`
|
||||
: 'Select a study to get started, or ask me to help you create a new one.';
|
||||
// Welcome message based on study and canvas context
|
||||
const welcomeMessage = useMemo(() => {
|
||||
if (selectedStudy) {
|
||||
return `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`;
|
||||
}
|
||||
if (nodes.length > 0) {
|
||||
const dvCount = nodes.filter(n => n.type === 'designVar').length;
|
||||
const objCount = nodes.filter(n => n.type === 'objective').length;
|
||||
return `I can see your canvas with ${dvCount} design variables and ${objCount} objectives. Ask me to analyze, validate, or create a study from this setup.`;
|
||||
}
|
||||
return 'Select a study to get started, or build an optimization in the Canvas Builder.';
|
||||
}, [selectedStudy, nodes]);
|
||||
|
||||
// Collapsed state - just show toggle button
|
||||
if (!isOpen) {
|
||||
|
||||
@@ -30,22 +30,25 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
// Map tool names to friendly labels and icons
|
||||
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color?: string }> = {
|
||||
// Study tools
|
||||
list_studies: { label: 'Listing Studies', icon: Database },
|
||||
get_study_status: { label: 'Getting Status', icon: FileSearch },
|
||||
create_study: { label: 'Creating Study', icon: Settings },
|
||||
create_study: { label: 'Creating Study', icon: Settings, color: 'text-green-400' },
|
||||
|
||||
// Optimization tools
|
||||
run_optimization: { label: 'Starting Optimization', icon: Play },
|
||||
run_optimization: { label: 'Starting Optimization', icon: Play, color: 'text-blue-400' },
|
||||
stop_optimization: { label: 'Stopping Optimization', icon: XCircle },
|
||||
get_optimization_status: { label: 'Checking Progress', icon: BarChart2 },
|
||||
|
||||
// Analysis tools
|
||||
get_trial_data: { label: 'Querying Trials', icon: Database },
|
||||
query_trials: { label: 'Querying Trials', icon: Database },
|
||||
get_trial_details: { label: 'Getting Trial Details', icon: FileSearch },
|
||||
analyze_convergence: { label: 'Analyzing Convergence', icon: BarChart2 },
|
||||
compare_trials: { label: 'Comparing Trials', icon: BarChart2 },
|
||||
get_best_design: { label: 'Getting Best Design', icon: CheckCircle },
|
||||
get_optimization_summary: { label: 'Getting Summary', icon: BarChart2 },
|
||||
|
||||
// Reporting tools
|
||||
generate_report: { label: 'Generating Report', icon: FileText },
|
||||
@@ -56,6 +59,25 @@ const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ cla
|
||||
recommend_method: { label: 'Recommending Method', icon: Settings },
|
||||
query_extractors: { label: 'Listing Extractors', icon: Database },
|
||||
|
||||
// Config tools (read)
|
||||
read_study_config: { label: 'Reading Config', icon: FileSearch },
|
||||
read_study_readme: { label: 'Reading README', icon: FileText },
|
||||
|
||||
// === WRITE TOOLS (Power Mode) ===
|
||||
add_design_variable: { label: 'Adding Design Variable', icon: Settings, color: 'text-amber-400' },
|
||||
add_extractor: { label: 'Adding Extractor', icon: Settings, color: 'text-amber-400' },
|
||||
add_objective: { label: 'Adding Objective', icon: Settings, color: 'text-amber-400' },
|
||||
add_constraint: { label: 'Adding Constraint', icon: Settings, color: 'text-amber-400' },
|
||||
update_spec_field: { label: 'Updating Field', icon: Settings, color: 'text-amber-400' },
|
||||
remove_node: { label: 'Removing Node', icon: XCircle, color: 'text-red-400' },
|
||||
|
||||
// === INTERVIEW TOOLS ===
|
||||
start_interview: { label: 'Starting Interview', icon: HelpCircle, color: 'text-purple-400' },
|
||||
interview_record: { label: 'Recording Answer', icon: CheckCircle, color: 'text-purple-400' },
|
||||
interview_advance: { label: 'Advancing Interview', icon: Play, color: 'text-purple-400' },
|
||||
interview_status: { label: 'Checking Progress', icon: BarChart2, color: 'text-purple-400' },
|
||||
interview_finalize: { label: 'Creating Study', icon: CheckCircle, color: 'text-green-400' },
|
||||
|
||||
// Admin tools (power mode)
|
||||
edit_file: { label: 'Editing File', icon: FileText },
|
||||
create_file: { label: 'Creating File', icon: FileText },
|
||||
@@ -104,7 +126,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
|
||||
)}
|
||||
|
||||
{/* Tool icon */}
|
||||
<Icon className="w-4 h-4 text-dark-400 flex-shrink-0" />
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${info.color || 'text-dark-400'}`} />
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 text-sm text-dark-200 truncate">{info.label}</span>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -14,25 +14,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { Card } from '../components/common/Card';
|
||||
|
||||
// Lazy load charts
|
||||
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
|
||||
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
|
||||
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
|
||||
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
|
||||
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
|
||||
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
|
||||
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
|
||||
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
|
||||
|
||||
const ChartLoading = () => (
|
||||
<div className="flex items-center justify-center h-64 text-dark-400">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm animate-pulse">Loading chart...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { ConvergencePlot } from '../components/ConvergencePlot';
|
||||
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
|
||||
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
|
||||
import { ParetoPlot } from '../components/ParetoPlot';
|
||||
|
||||
const NoData = ({ message = 'No data available' }: { message?: string }) => (
|
||||
<div className="flex items-center justify-center h-64 text-dark-500">
|
||||
@@ -383,15 +368,12 @@ export default function Analysis() {
|
||||
{/* Convergence Plot */}
|
||||
{trials.length > 0 && (
|
||||
<Card title="Convergence Plot">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyConvergencePlot
|
||||
trials={trials}
|
||||
objectiveIndex={0}
|
||||
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
height={350}
|
||||
/>
|
||||
</Suspense>
|
||||
<ConvergencePlot
|
||||
trials={trials}
|
||||
objectiveIndex={0}
|
||||
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -455,30 +437,24 @@ export default function Analysis() {
|
||||
{/* Parameter Importance */}
|
||||
{trials.length > 0 && metadata?.design_variables && (
|
||||
<Card title="Parameter Importance">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParameterImportance
|
||||
trials={trials}
|
||||
designVariables={metadata.design_variables}
|
||||
objectiveIndex={0}
|
||||
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
|
||||
height={400}
|
||||
/>
|
||||
</Suspense>
|
||||
<ParameterImportanceChart
|
||||
trials={trials}
|
||||
designVariables={metadata.design_variables}
|
||||
objectiveIndex={0}
|
||||
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Parallel Coordinates */}
|
||||
{trials.length > 0 && metadata && (
|
||||
<Card title="Parallel Coordinates">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParallelCoordinates
|
||||
trials={trials}
|
||||
objectives={metadata.objectives || []}
|
||||
designVariables={metadata.design_variables || []}
|
||||
paretoFront={paretoFront}
|
||||
height={450}
|
||||
/>
|
||||
</Suspense>
|
||||
<ParallelCoordinatesPlot
|
||||
paretoData={trials}
|
||||
objectives={metadata.objectives || []}
|
||||
designVariables={metadata.design_variables || []}
|
||||
paretoFront={paretoFront}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
@@ -508,14 +484,11 @@ export default function Analysis() {
|
||||
{/* Pareto Front Plot */}
|
||||
{paretoFront.length > 0 && (
|
||||
<Card title="Pareto Front">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParetoPlot
|
||||
trials={trials}
|
||||
paretoFront={paretoFront}
|
||||
objectives={metadata?.objectives || []}
|
||||
height={500}
|
||||
/>
|
||||
</Suspense>
|
||||
<ParetoPlot
|
||||
paretoData={paretoFront}
|
||||
objectives={metadata?.objectives || []}
|
||||
allTrials={trials}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -550,16 +523,10 @@ export default function Analysis() {
|
||||
{/* Correlations Tab */}
|
||||
{activeTab === 'correlations' && (
|
||||
<div className="space-y-6">
|
||||
{/* Correlation Heatmap */}
|
||||
{/* Correlation Analysis */}
|
||||
{trials.length > 2 && (
|
||||
<Card title="Parameter-Objective Correlation Matrix">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyCorrelationHeatmap
|
||||
trials={trials}
|
||||
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
|
||||
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
|
||||
/>
|
||||
</Suspense>
|
||||
<Card title="Parameter-Objective Correlation Analysis">
|
||||
<CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -612,11 +579,22 @@ export default function Analysis() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Feasibility Over Time Chart */}
|
||||
<Card title="Feasibility Rate Over Time">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyFeasibilityChart trials={trials} height={350} />
|
||||
</Suspense>
|
||||
{/* Feasibility Summary */}
|
||||
<Card title="Feasibility Analysis">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex-1 bg-dark-700 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-500"
|
||||
style={{ width: `${stats.feasibilityRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-green-400">{stats.feasibilityRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-dark-400 text-sm">
|
||||
{stats.feasible} of {stats.total} trials satisfy all constraints
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Infeasible Trials List */}
|
||||
@@ -683,11 +661,38 @@ export default function Analysis() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Surrogate Quality Charts */}
|
||||
<Card title="Surrogate Model Analysis">
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlySurrogateQuality trials={trials} height={400} />
|
||||
</Suspense>
|
||||
{/* Surrogate Performance Summary */}
|
||||
<Card title="Surrogate Model Performance">
|
||||
<div className="grid grid-cols-2 gap-6 p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-dark-300 mb-3">Trial Distribution</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-dark-200">FEA: {stats.feaTrials} trials</span>
|
||||
<span className="text-dark-400 ml-auto">
|
||||
{((stats.feaTrials / stats.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-dark-200">NN: {stats.nnTrials} trials</span>
|
||||
<span className="text-dark-400 ml-auto">
|
||||
{((stats.nnTrials / stats.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-dark-300 mb-3">Efficiency Gains</h4>
|
||||
<div className="text-center p-4 bg-dark-750 rounded-lg">
|
||||
<div className="text-3xl font-bold text-primary-400">
|
||||
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400 mt-1">Effective Speedup</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -700,9 +705,36 @@ export default function Analysis() {
|
||||
Compare different optimization runs within this study. Studies with adaptive optimization
|
||||
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
|
||||
</p>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyRunComparison runs={runs} height={400} />
|
||||
</Suspense>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-600">
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trials</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Best Value</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Avg Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.run_id} className="border-b border-dark-700">
|
||||
<td className="py-2 px-3 font-mono text-white">{run.name || `Run ${run.run_id}`}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
run.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
|
||||
}`}>
|
||||
{run.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-dark-200">{run.trial_count}</td>
|
||||
<td className="py-2 px-3 font-mono text-green-400">{run.best_value?.toExponential(4) || 'N/A'}</td>
|
||||
<td className="py-2 px-3 font-mono text-dark-300">{run.avg_value?.toExponential(4) || 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
|
||||
@@ -21,19 +21,6 @@ import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
|
||||
import { NivoParallelCoordinates } from '../components/charts';
|
||||
import type { Trial } from '../types';
|
||||
|
||||
// Lazy load Plotly components for better initial load performance
|
||||
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
|
||||
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
|
||||
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
|
||||
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
|
||||
|
||||
// Loading placeholder for lazy components
|
||||
const ChartLoading = () => (
|
||||
<div className="flex items-center justify-center h-64 text-dark-400">
|
||||
<div className="animate-pulse">Loading chart...</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
|
||||
@@ -62,8 +49,8 @@ export default function Dashboard() {
|
||||
const [paretoFront, setParetoFront] = useState<any[]>([]);
|
||||
const [allTrialsRaw, setAllTrialsRaw] = useState<any[]>([]); // All trials for parallel coordinates
|
||||
|
||||
// Chart library toggle: 'nivo' (dark theme, default), 'plotly' (more interactive), or 'recharts' (simple)
|
||||
const [chartLibrary, setChartLibrary] = useState<'nivo' | 'plotly' | 'recharts'>('nivo');
|
||||
// Chart library toggle: 'nivo' (dark theme, default) or 'recharts' (simple)
|
||||
const [chartLibrary, setChartLibrary] = useState<'nivo' | 'recharts'>('nivo');
|
||||
|
||||
// Process status for tracker panels
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
@@ -464,18 +451,7 @@ export default function Dashboard() {
|
||||
}`}
|
||||
title="Modern Nivo charts with dark theme (recommended)"
|
||||
>
|
||||
Nivo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChartLibrary('plotly')}
|
||||
className={`px-3 py-1.5 text-sm transition-colors ${
|
||||
chartLibrary === 'plotly'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
|
||||
}`}
|
||||
title="Interactive Plotly charts with zoom, pan, and export"
|
||||
>
|
||||
Plotly
|
||||
Advanced
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChartLibrary('recharts')}
|
||||
@@ -570,22 +546,11 @@ export default function Dashboard() {
|
||||
title="Pareto Front"
|
||||
subtitle={`${paretoFront.length} Pareto-optimal solutions | ${studyMetadata.sampler || 'NSGA-II'} | ${studyMetadata.objectives?.length || 2} objectives`}
|
||||
>
|
||||
{chartLibrary === 'plotly' ? (
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParetoPlot
|
||||
trials={allTrialsRaw}
|
||||
paretoFront={paretoFront}
|
||||
objectives={studyMetadata.objectives}
|
||||
height={300}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ParetoPlot
|
||||
paretoData={paretoFront}
|
||||
objectives={studyMetadata.objectives}
|
||||
allTrials={allTrialsRaw}
|
||||
/>
|
||||
)}
|
||||
<ParetoPlot
|
||||
paretoData={paretoFront}
|
||||
objectives={studyMetadata.objectives}
|
||||
allTrials={allTrialsRaw}
|
||||
/>
|
||||
</ExpandableChart>
|
||||
</div>
|
||||
)}
|
||||
@@ -605,16 +570,6 @@ export default function Dashboard() {
|
||||
paretoFront={paretoFront}
|
||||
height={380}
|
||||
/>
|
||||
) : chartLibrary === 'plotly' ? (
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParallelCoordinates
|
||||
trials={allTrialsRaw}
|
||||
objectives={studyMetadata.objectives}
|
||||
designVariables={studyMetadata.design_variables}
|
||||
paretoFront={paretoFront}
|
||||
height={350}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ParallelCoordinatesPlot
|
||||
paretoData={allTrialsRaw}
|
||||
@@ -634,24 +589,12 @@ export default function Dashboard() {
|
||||
title="Convergence"
|
||||
subtitle={`Best ${studyMetadata?.objectives?.[0]?.name || 'Objective'} over ${allTrialsRaw.length} trials`}
|
||||
>
|
||||
{chartLibrary === 'plotly' ? (
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyConvergencePlot
|
||||
trials={allTrialsRaw}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
height={280}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ConvergencePlot
|
||||
trials={allTrialsRaw}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
/>
|
||||
)}
|
||||
<ConvergencePlot
|
||||
trials={allTrialsRaw}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
direction="minimize"
|
||||
/>
|
||||
</ExpandableChart>
|
||||
</div>
|
||||
)}
|
||||
@@ -663,32 +606,16 @@ export default function Dashboard() {
|
||||
title="Parameter Importance"
|
||||
subtitle={`Correlation with ${studyMetadata?.objectives?.[0]?.name || 'Objective'}`}
|
||||
>
|
||||
{chartLibrary === 'plotly' ? (
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParameterImportance
|
||||
trials={allTrialsRaw}
|
||||
designVariables={
|
||||
studyMetadata?.design_variables?.length > 0
|
||||
? studyMetadata.design_variables
|
||||
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
|
||||
}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
height={280}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ParameterImportanceChart
|
||||
trials={allTrialsRaw}
|
||||
designVariables={
|
||||
studyMetadata?.design_variables?.length > 0
|
||||
? studyMetadata.design_variables
|
||||
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
|
||||
}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
/>
|
||||
)}
|
||||
<ParameterImportanceChart
|
||||
trials={allTrialsRaw}
|
||||
designVariables={
|
||||
studyMetadata?.design_variables?.length > 0
|
||||
? studyMetadata.design_variables
|
||||
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
|
||||
}
|
||||
objectiveIndex={0}
|
||||
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
|
||||
/>
|
||||
</ExpandableChart>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -394,18 +394,32 @@ const Home: React.FC = () => {
|
||||
<p className="text-dark-400 text-sm">Study Documentation</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSelectStudy(selectedPreview)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
boxShadow: '0 4px 15px rgba(0, 212, 230, 0.3)'
|
||||
}}
|
||||
>
|
||||
Open
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/canvas/${selectedPreview.id}`)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg transition-all font-medium whitespace-nowrap hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'rgba(8, 15, 26, 0.85)',
|
||||
border: '1px solid rgba(0, 212, 230, 0.3)',
|
||||
color: '#00d4e6'
|
||||
}}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectStudy(selectedPreview)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
boxShadow: '0 4px 15px rgba(0, 212, 230, 0.3)'
|
||||
}}
|
||||
>
|
||||
Open
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Study Quick Stats */}
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
ExternalLink,
|
||||
Zap,
|
||||
List,
|
||||
LucideIcon
|
||||
LucideIcon,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { Card } from '../components/common/Card';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -642,13 +642,15 @@ export default function Insights() {
|
||||
Open Full View
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFullscreen(true)}
|
||||
className="p-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
{activeInsight.html_path && (
|
||||
<button
|
||||
onClick={() => setFullscreen(true)}
|
||||
className="p-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -674,49 +676,43 @@ export default function Insights() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plotly Figure */}
|
||||
{/* Insight Result */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
{activeInsight.plotly_figure ? (
|
||||
<div className="bg-dark-900" style={{ height: '600px' }}>
|
||||
<Plot
|
||||
data={activeInsight.plotly_figure.data}
|
||||
layout={{
|
||||
...activeInsight.plotly_figure.layout,
|
||||
autosize: true,
|
||||
margin: { l: 60, r: 60, t: 60, b: 60 },
|
||||
paper_bgcolor: '#111827',
|
||||
plot_bgcolor: '#1f2937',
|
||||
font: { color: 'white' }
|
||||
}}
|
||||
config={{
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-dark-400 p-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mb-4" />
|
||||
<p className="text-lg font-medium text-white mb-2">Insight Generated Successfully</p>
|
||||
<div className="flex flex-col items-center justify-center h-64 text-dark-400 p-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mb-4" />
|
||||
<p className="text-lg font-medium text-white mb-2">Insight Generated Successfully</p>
|
||||
{activeInsight.html_path ? (
|
||||
<>
|
||||
<p className="text-sm text-center mb-4">
|
||||
Click the button below to view the interactive visualization.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.open(`/api/insights/studies/${selectedStudy?.id}/view/${activeInsight.insight_type}`, '_blank')}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
Open Interactive Visualization
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-center">
|
||||
This insight generates HTML files. Click "Open Full View" to see the visualization.
|
||||
The visualization has been generated. Check the study's insights folder.
|
||||
</p>
|
||||
{activeInsight.summary?.html_files && (
|
||||
<div className="mt-4 text-sm">
|
||||
<p className="text-dark-400 mb-2">Generated files:</p>
|
||||
<ul className="space-y-1">
|
||||
{(activeInsight.summary.html_files as string[]).slice(0, 4).map((f: string, i: number) => (
|
||||
<li key={i} className="text-dark-300">
|
||||
{f.split(/[/\\]/).pop()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{activeInsight.summary?.html_files && (
|
||||
<div className="mt-4 text-sm">
|
||||
<p className="text-dark-400 mb-2">Generated files:</p>
|
||||
<ul className="space-y-1">
|
||||
{(activeInsight.summary.html_files as string[]).slice(0, 4).map((f: string, i: number) => (
|
||||
<li key={i} className="text-dark-300 flex items-center gap-2">
|
||||
<FileText className="w-3 h-3" />
|
||||
{f.split(/[/\\]/).pop()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Generate Another */}
|
||||
@@ -736,8 +732,8 @@ export default function Insights() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
{fullscreen && activeInsight?.plotly_figure && (
|
||||
{/* Fullscreen Modal - now opens external HTML */}
|
||||
{fullscreen && activeInsight && (
|
||||
<div className="fixed inset-0 z-50 bg-dark-900 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-600">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
@@ -750,23 +746,24 @@ export default function Insights() {
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<Plot
|
||||
data={activeInsight.plotly_figure.data}
|
||||
layout={{
|
||||
...activeInsight.plotly_figure.layout,
|
||||
autosize: true,
|
||||
paper_bgcolor: '#111827',
|
||||
plot_bgcolor: '#1f2937',
|
||||
font: { color: 'white' }
|
||||
}}
|
||||
config={{
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div className="flex-1 p-4 flex items-center justify-center">
|
||||
{activeInsight.html_path ? (
|
||||
<iframe
|
||||
src={`/api/insights/studies/${selectedStudy?.id}/view/${activeInsight.insight_type}`}
|
||||
className="w-full h-full border-0 rounded-lg"
|
||||
title={activeInsight.insight_name || activeInsight.insight_type}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-dark-400">
|
||||
<p className="text-lg mb-4">No interactive visualization available for this insight.</p>
|
||||
<button
|
||||
onClick={() => setFullscreen(false)}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -278,7 +278,7 @@ export default function Setup() {
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('canvas')}
|
||||
onClick={() => navigate(`/canvas/${selectedStudy?.id || ''}`)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-primary-600 text-white"
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
@@ -333,7 +333,7 @@ export default function Setup() {
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('canvas')}
|
||||
onClick={() => navigate(`/canvas/${selectedStudy?.id || ''}`)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-dark-800 text-dark-300 hover:text-white hover:bg-dark-700"
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// AtomizerSpec v2.0 types (unified configuration)
|
||||
export * from './atomizer-spec';
|
||||
|
||||
// Study types
|
||||
export interface Study {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user