feat: Add Claude Code terminal integration to dashboard
- Add embedded Claude Code terminal with xterm.js for full CLI experience - Create WebSocket PTY backend for real-time terminal communication - Add terminal status endpoint to check CLI availability - Update dashboard to use Claude Code terminal instead of API chat - Add optimization control panel with start/stop/validate actions - Add study context provider for global state management - Update frontend with new dependencies (xterm.js addons) - Comprehensive README documentation for all new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
450
atomizer-dashboard/frontend/src/components/ClaudeChat.tsx
Normal file
450
atomizer-dashboard/frontend/src/components/ClaudeChat.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Send,
|
||||
Bot,
|
||||
User,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
AlertCircle,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: Array<{
|
||||
tool: string;
|
||||
input: Record<string, any>;
|
||||
result_preview: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ClaudeChatProps {
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ClaudeChat: React.FC<ClaudeChatProps> = ({
|
||||
isExpanded = false,
|
||||
onToggleExpand,
|
||||
onClose
|
||||
}) => {
|
||||
const { selectedStudy } = useStudy();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiAvailable, setApiAvailable] = useState<boolean | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Check API status on mount
|
||||
useEffect(() => {
|
||||
checkApiStatus();
|
||||
}, []);
|
||||
|
||||
// Load suggestions when study changes
|
||||
useEffect(() => {
|
||||
loadSuggestions();
|
||||
}, [selectedStudy]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const checkApiStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/claude/status');
|
||||
const data = await response.json();
|
||||
setApiAvailable(data.available);
|
||||
if (!data.available) {
|
||||
setError(data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setApiAvailable(false);
|
||||
setError('Could not connect to Claude API');
|
||||
}
|
||||
};
|
||||
|
||||
const loadSuggestions = async () => {
|
||||
try {
|
||||
const url = selectedStudy
|
||||
? `/api/claude/suggestions?study_id=${selectedStudy.id}`
|
||||
: '/api/claude/suggestions';
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
setSuggestions(data.suggestions || []);
|
||||
} catch (err) {
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (messageText?: string) => {
|
||||
const text = messageText || input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setError(null);
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/claude/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
study_id: selectedStudy?.id,
|
||||
conversation_history: messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to get response');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: data.response,
|
||||
timestamp: new Date(),
|
||||
toolCalls: data.tool_calls
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to send message');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const clearConversation = () => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const toggleToolExpand = (toolId: string) => {
|
||||
setExpandedTools(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(toolId)) {
|
||||
newSet.delete(toolId);
|
||||
} else {
|
||||
newSet.add(toolId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Render tool call indicator
|
||||
const renderToolCalls = (toolCalls: Message['toolCalls'], messageId: string) => {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{toolCalls.map((tool, index) => {
|
||||
const toolId = `${messageId}-tool-${index}`;
|
||||
const isExpanded = expandedTools.has(toolId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-dark-700/50 rounded-lg border border-dark-600 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleToolExpand(toolId)}
|
||||
className="w-full px-3 py-2 flex items-center justify-between text-xs hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-3 h-3 text-primary-400" />
|
||||
<span className="text-dark-300">Used tool: </span>
|
||||
<span className="text-primary-400 font-mono">{tool.tool}</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3 h-3 text-dark-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 border-t border-dark-600 text-xs">
|
||||
<div className="text-dark-400 mb-1">Input:</div>
|
||||
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto">
|
||||
{JSON.stringify(tool.input, null, 2)}
|
||||
</pre>
|
||||
<div className="text-dark-400 mt-2 mb-1">Result preview:</div>
|
||||
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
|
||||
{tool.result_preview}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
|
||||
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-white">Claude Code</span>
|
||||
{selectedStudy && (
|
||||
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
|
||||
{selectedStudy.id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={clearConversation}
|
||||
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onToggleExpand && (
|
||||
<button
|
||||
onClick={onToggleExpand}
|
||||
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Status Warning */}
|
||||
{apiAvailable === false && (
|
||||
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-yellow-400 text-sm">
|
||||
{error || 'Claude API not available'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Sparkles className="w-12 h-12 mx-auto mb-4 text-primary-400 opacity-50" />
|
||||
<p className="text-dark-300 mb-2">Ask me anything about your optimization</p>
|
||||
<p className="text-dark-500 text-sm mb-6">
|
||||
I can analyze results, explain concepts, and help you improve your designs.
|
||||
</p>
|
||||
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 justify-center max-w-md mx-auto">
|
||||
{suggestions.slice(0, 6).map((suggestion, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => sendMessage(suggestion)}
|
||||
disabled={isLoading || apiAvailable === false}
|
||||
className="px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:opacity-50
|
||||
rounded-lg text-sm text-dark-300 hover:text-white transition-colors
|
||||
border border-dark-600 hover:border-dark-500"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary-600 text-white px-4 py-2'
|
||||
: 'bg-dark-700 text-dark-200 px-4 py-3'
|
||||
}`}
|
||||
>
|
||||
{msg.role === 'assistant' ? (
|
||||
<>
|
||||
<div className="prose prose-sm prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Simplified markdown styling for chat
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
code: ({ inline, children }: any) =>
|
||||
inline ? (
|
||||
<code className="px-1 py-0.5 bg-dark-600 rounded text-primary-400 text-xs">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<pre className="p-2 bg-dark-800 rounded overflow-x-auto text-xs my-2">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="text-xs border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-dark-600 px-2 py-1 bg-dark-800 text-left">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-dark-600 px-2 py-1">{children}</td>
|
||||
),
|
||||
strong: ({ children }) => <strong className="text-white">{children}</strong>,
|
||||
h1: ({ children }) => <h1 className="text-lg font-bold text-white mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-3 mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{renderToolCalls(msg.toolCalls, msg.id)}
|
||||
</>
|
||||
) : (
|
||||
<p>{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-dark-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||
</div>
|
||||
<div className="bg-dark-700 rounded-lg px-4 py-3 text-dark-400">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>Thinking</span>
|
||||
<span className="flex gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-red-900/20 border border-red-800/30 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-dark-600 bg-dark-800">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={apiAvailable === false ? 'API not available...' : 'Ask about your optimization...'}
|
||||
disabled={isLoading || apiAvailable === false}
|
||||
className="flex-1 px-4 py-2.5 bg-dark-700 border border-dark-600 rounded-lg
|
||||
text-white placeholder-dark-400 focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage()}
|
||||
disabled={!input.trim() || isLoading || apiAvailable === false}
|
||||
className="px-4 py-2.5 bg-primary-600 hover:bg-primary-500 disabled:opacity-50
|
||||
disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500 mt-2 text-center">
|
||||
Claude can query your study data, analyze results, and help improve your optimization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeChat;
|
||||
Reference in New Issue
Block a user