Files
Atomizer/atomizer-dashboard/frontend/src/components/studio/StudioChat.tsx
Anto01 a26914bbe8 feat: Add Studio UI, intake system, and extractor improvements
Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:02:30 -05:00

376 lines
14 KiB
TypeScript

/**
* StudioChat - Context-aware AI chat for Studio
*
* Uses the existing useChat hook to communicate with Claude via WebSocket.
* Injects model files and context documents into the conversation.
*/
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { Send, Loader2, Sparkles, FileText, Wifi, WifiOff, Bot, User, File, AlertCircle } from 'lucide-react';
import { useChat } from '../../hooks/useChat';
import { useSpecStore, useSpec } from '../../hooks/useSpecStore';
import { MarkdownRenderer } from '../MarkdownRenderer';
import { ToolCallCard } from '../chat/ToolCallCard';
interface StudioChatProps {
draftId: string;
contextFiles: string[];
contextContent: string;
modelFiles: string[];
onSpecUpdated: () => void;
}
export const StudioChat: React.FC<StudioChatProps> = ({
draftId,
contextFiles,
contextContent,
modelFiles,
onSpecUpdated,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState('');
const [hasInjectedContext, setHasInjectedContext] = useState(false);
// Get spec store for canvas updates
const spec = useSpec();
const { reloadSpec, setSpecFromWebSocket } = useSpecStore();
// Build canvas state with full context for Claude
const canvasState = useMemo(() => ({
nodes: [],
edges: [],
studyName: draftId,
studyPath: `_inbox/${draftId}`,
// Include file info for Claude context
modelFiles,
contextFiles,
contextContent: contextContent.substring(0, 50000), // Limit context size
}), [draftId, modelFiles, contextFiles, contextContent]);
// Use the chat hook with WebSocket
// Power mode gives Claude write permissions to modify the spec
const {
messages,
isThinking,
error,
isConnected,
sendMessage,
updateCanvasState,
} = useChat({
studyId: draftId,
mode: 'power', // Power mode = --dangerously-skip-permissions = can write files
useWebSocket: true,
canvasState,
onError: (err) => console.error('[StudioChat] Error:', err),
onSpecUpdated: (newSpec) => {
// Claude modified the spec - update the store directly
console.log('[StudioChat] Spec updated by Claude');
setSpecFromWebSocket(newSpec, draftId);
onSpecUpdated();
},
onCanvasModification: (modification) => {
// Claude wants to modify canvas - reload the spec
console.log('[StudioChat] Canvas modification:', modification);
reloadSpec();
onSpecUpdated();
},
});
// Update canvas state when context changes
useEffect(() => {
updateCanvasState(canvasState);
}, [canvasState, updateCanvasState]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input
useEffect(() => {
inputRef.current?.focus();
}, []);
// Build context summary for display
const contextSummary = useMemo(() => {
const parts: string[] = [];
if (modelFiles.length > 0) {
parts.push(`${modelFiles.length} model file${modelFiles.length > 1 ? 's' : ''}`);
}
if (contextFiles.length > 0) {
parts.push(`${contextFiles.length} context doc${contextFiles.length > 1 ? 's' : ''}`);
}
if (contextContent) {
parts.push(`${contextContent.length.toLocaleString()} chars context`);
}
return parts.join(', ');
}, [modelFiles, contextFiles, contextContent]);
const handleSend = () => {
if (!input.trim() || isThinking) return;
let messageToSend = input.trim();
// On first message, inject full context so Claude has everything it needs
if (!hasInjectedContext && (modelFiles.length > 0 || contextContent)) {
const contextParts: string[] = [];
// Add model files info
if (modelFiles.length > 0) {
contextParts.push(`**Model Files Uploaded:**\n${modelFiles.map(f => `- ${f}`).join('\n')}`);
}
// Add context document content (full text)
if (contextContent) {
contextParts.push(`**Context Documents Content:**\n\`\`\`\n${contextContent.substring(0, 30000)}\n\`\`\``);
}
// Add current spec state
if (spec) {
const dvCount = spec.design_variables?.length || 0;
const objCount = spec.objectives?.length || 0;
const extCount = spec.extractors?.length || 0;
if (dvCount > 0 || objCount > 0 || extCount > 0) {
contextParts.push(`**Current Configuration:** ${dvCount} design variables, ${objCount} objectives, ${extCount} extractors`);
}
}
if (contextParts.length > 0) {
messageToSend = `${contextParts.join('\n\n')}\n\n---\n\n**User Request:** ${messageToSend}`;
}
setHasInjectedContext(true);
}
sendMessage(messageToSend);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Welcome message for empty state
const showWelcome = messages.length === 0;
// Check if we have any context
const hasContext = modelFiles.length > 0 || contextContent.length > 0;
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b border-dark-700 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-400" />
<span className="font-medium text-white">Studio Assistant</span>
</div>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
isConnected
? 'text-green-400 bg-green-400/10'
: 'text-red-400 bg-red-400/10'
}`}>
{isConnected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Context indicator */}
{contextSummary && (
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1 text-amber-400 bg-amber-400/10 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
<span>{contextSummary}</span>
</div>
{hasContext && !hasInjectedContext && (
<span className="text-dark-500">Will be sent with first message</span>
)}
{hasInjectedContext && (
<span className="text-green-500">Context sent</span>
)}
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Welcome message with context awareness */}
{showWelcome && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 bg-dark-700 rounded-lg px-4 py-3 text-sm text-dark-100">
<MarkdownRenderer content={hasContext
? `I can see you've uploaded files. Here's what I have access to:
${modelFiles.length > 0 ? `**Model Files:** ${modelFiles.join(', ')}` : ''}
${contextContent ? `\n**Context Document:** ${contextContent.substring(0, 200)}...` : ''}
Tell me what you want to optimize and I'll help you configure the study!`
: `Welcome to Atomizer Studio! I'm here to help you configure your optimization study.
**What I can do:**
- Read your uploaded context documents
- Help set up design variables, objectives, and constraints
- Create extractors for physics outputs
- Suggest optimization strategies
Upload your model files and any requirements documents, then tell me what you want to optimize!`} />
</div>
</div>
)}
{/* File context display (only if we have files but no messages yet) */}
{showWelcome && modelFiles.length > 0 && (
<div className="bg-dark-800/50 rounded-lg p-3 border border-dark-700">
<p className="text-xs text-dark-400 mb-2 font-medium">Loaded Files:</p>
<div className="flex flex-wrap gap-2">
{modelFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-400 px-2 py-1 rounded">
<File className="w-3 h-3" />
{file}
</span>
))}
{contextFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-400 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Chat messages */}
{messages.map((msg) => {
const isAssistant = msg.role === 'assistant';
const isSystem = msg.role === 'system';
// System messages
if (isSystem) {
return (
<div key={msg.id} 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">
{msg.content}
</div>
</div>
);
}
return (
<div
key={msg.id}
className={`flex gap-3 ${isAssistant ? '' : 'flex-row-reverse'}`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${
isAssistant
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-600 text-dark-300'
}`}
>
{isAssistant ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
</div>
{/* Message content */}
<div
className={`flex-1 max-w-[85%] rounded-lg px-4 py-3 text-sm ${
isAssistant
? 'bg-dark-700 text-dark-100'
: 'bg-primary-500 text-white ml-auto'
}`}
>
{isAssistant ? (
<>
{msg.content && <MarkdownRenderer content={msg.content} />}
{msg.isStreaming && !msg.content && (
<span className="text-dark-400">Thinking...</span>
)}
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{msg.toolCalls.map((tool, idx) => (
<ToolCallCard key={idx} toolCall={tool} />
))}
</div>
)}
</>
) : (
<span className="whitespace-pre-wrap">{msg.content}</span>
)}
</div>
</div>
);
})}
{/* Thinking indicator */}
{isThinking && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="bg-dark-700 rounded-lg px-4 py-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 text-primary-400 animate-spin" />
<span className="text-sm text-dark-300">Thinking...</span>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/20 text-red-400">
<AlertCircle className="w-4 h-4" />
</div>
<div className="flex-1 px-4 py-3 bg-red-500/10 rounded-lg text-sm text-red-400 border border-red-500/30">
{error}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-dark-700 flex-shrink-0">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isConnected ? "Ask about your optimization..." : "Connecting..."}
disabled={!isConnected}
rows={1}
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white placeholder-dark-400 resize-none focus:outline-none focus:border-primary-400 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isThinking || !isConnected}
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isThinking ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{!isConnected && (
<p className="text-xs text-dark-500 mt-1">
Waiting for connection to Claude...
</p>
)}
</div>
</div>
);
};
export default StudioChat;