feat: Claude Web UI POC with streaming and tool visualization
Initial implementation of a web-based Claude Code interface with: Backend (Node.js + Express + WebSocket): - Claude CLI spawning with JSON stream mode - Session management with resume support (--continue flag) - Session history API endpoint - Real-time WebSocket communication - --include-partial-messages for live streaming Frontend (React + Vite + Tailwind): - Modern dark theme UI (Discord/Slack style) - Live text streaming with content_block_delta handling - Markdown rendering with react-markdown + remark-gfm - Syntax highlighting with react-syntax-highlighter (One Dark) - Collapsible high-tech tool cards with: - Tool-specific icons and colors - Compact summaries (Read, Glob, Bash, Edit, etc.) - Expandable JSON details - Session history loading on resume - Project directory selection - Resume session toggle Docker: - Multi-container setup (backend + nginx frontend) - Isolated Claude config directory - Host network mode for backend Built collaboratively by Neko (VPS Claude) and Web-UI Claude, with Web-UI Claude implementing most frontend features while running inside the interface itself (meta-programming!). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
300
frontend/src/hooks/useClaudeSession.js
Normal file
300
frontend/src/hooks/useClaudeSession.js
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
||||
|
||||
export function useClaudeSession() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [sessionActive, setSessionActive] = useState(false);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [currentProject, setCurrentProject] = useState(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const currentAssistantMessage = useRef(null);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setConnected(false);
|
||||
setSessionActive(false);
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
setError('Connection error');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = useCallback((data) => {
|
||||
console.log('Received:', data.type, data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'session_started':
|
||||
setSessionActive(true);
|
||||
setCurrentProject(data.project);
|
||||
setMessages(prev => [...prev, {
|
||||
type: 'system',
|
||||
content: `Session started in ${data.project}`,
|
||||
timestamp: data.timestamp
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'session_ended':
|
||||
setSessionActive(false);
|
||||
setIsProcessing(false);
|
||||
setMessages(prev => [...prev, {
|
||||
type: 'system',
|
||||
content: `Session ended (code: ${data.code})`,
|
||||
timestamp: data.timestamp
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'claude_event':
|
||||
handleClaudeEvent(data.event);
|
||||
break;
|
||||
|
||||
case 'raw_output':
|
||||
console.log('Raw:', data.content);
|
||||
break;
|
||||
|
||||
case 'stderr':
|
||||
console.log('Stderr:', data.content);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setError(data.message);
|
||||
setIsProcessing(false);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClaudeEvent = useCallback((event) => {
|
||||
// Debug: log all event types to understand the structure
|
||||
console.log('Claude Event:', event.type, event);
|
||||
|
||||
// Handle different Claude event types
|
||||
if (event.type === 'assistant') {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Check if this is a content block
|
||||
if (event.message?.content) {
|
||||
const newMessages = [];
|
||||
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
// Only add text if we don't have a streaming message with similar content
|
||||
// (to avoid duplicates from stream_event + final assistant event)
|
||||
newMessages.push({
|
||||
type: 'assistant',
|
||||
content: block.text,
|
||||
timestamp: Date.now(),
|
||||
final: true // Mark as final message
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
// Tool use is embedded in assistant message content
|
||||
newMessages.push({
|
||||
type: 'tool_use',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
toolUseId: block.id,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
setMessages(prev => {
|
||||
// Check if last message is a streaming message - if so, replace it with final
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.type === 'assistant' && last.streaming) {
|
||||
// Replace streaming message with final content, keep other new messages (tool_use)
|
||||
const textMessages = newMessages.filter(m => m.type === 'assistant');
|
||||
const otherMessages = newMessages.filter(m => m.type !== 'assistant');
|
||||
|
||||
if (textMessages.length > 0) {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...textMessages[0], streaming: false },
|
||||
...otherMessages
|
||||
];
|
||||
}
|
||||
}
|
||||
return [...prev, ...newMessages];
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'user' && event.tool_use_result) {
|
||||
// Tool results come as 'user' events with tool_use_result
|
||||
const result = event.tool_use_result;
|
||||
setMessages(prev => [...prev, {
|
||||
type: 'tool_result',
|
||||
content: result.content,
|
||||
toolUseId: result.tool_use_id,
|
||||
isError: result.is_error || false,
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
} else if (event.type === 'content_block_delta') {
|
||||
// Streaming delta (direct)
|
||||
if (event.delta?.text) {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.type === 'assistant' && last.streaming) {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, content: last.content + event.delta.text }
|
||||
];
|
||||
}
|
||||
return [...prev, {
|
||||
type: 'assistant',
|
||||
content: event.delta.text,
|
||||
streaming: true,
|
||||
timestamp: Date.now()
|
||||
}];
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'stream_event' && event.event?.type === 'content_block_delta') {
|
||||
// Streaming delta (wrapped in stream_event)
|
||||
const deltaText = event.event?.delta?.text;
|
||||
if (deltaText) {
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.type === 'assistant' && last.streaming) {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, content: last.content + deltaText }
|
||||
];
|
||||
}
|
||||
return [...prev, {
|
||||
type: 'assistant',
|
||||
content: deltaText,
|
||||
streaming: true,
|
||||
timestamp: Date.now()
|
||||
}];
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'result') {
|
||||
// Final result - just stop processing
|
||||
setIsProcessing(false);
|
||||
} else if (event.type === 'system' && event.subtype === 'result') {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async (project) => {
|
||||
try {
|
||||
const encodedProject = encodeURIComponent(project);
|
||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
console.log(`Loaded ${data.messages.length} messages from history`);
|
||||
setMessages(data.messages);
|
||||
return data.sessionId;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load history:', err);
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const startSession = useCallback(async (project = '/projects', resume = true) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setError('Not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load history before starting session if resuming
|
||||
if (resume) {
|
||||
await loadHistory(project);
|
||||
}
|
||||
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'start_session',
|
||||
project,
|
||||
resume
|
||||
}));
|
||||
}, [loadHistory]);
|
||||
|
||||
const sendMessage = useCallback((message) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
setError('Not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionActive) {
|
||||
setError('No active session');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message to display
|
||||
setMessages(prev => [...prev, {
|
||||
type: 'user',
|
||||
content: message,
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'user_message',
|
||||
message
|
||||
}));
|
||||
}, [sessionActive]);
|
||||
|
||||
const stopSession = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'stop_session' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
// Auto-connect on mount
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
connected,
|
||||
sessionActive,
|
||||
messages,
|
||||
currentProject,
|
||||
isProcessing,
|
||||
error,
|
||||
connect,
|
||||
startSession,
|
||||
sendMessage,
|
||||
stopSession,
|
||||
clearMessages,
|
||||
setError
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user