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:
36
backend/Dockerfile
Normal file
36
backend/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM node:20-slim
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Create node user home structure for claude
|
||||
RUN mkdir -p /home/node/.local/bin && \
|
||||
mkdir -p /home/node/.local/share/claude && \
|
||||
mkdir -p /home/node/.claude && \
|
||||
mkdir -p /home/node/.config/claude && \
|
||||
chown -R node:node /home/node
|
||||
|
||||
# Create symlink for claude binary
|
||||
RUN ln -s /home/node/.local/share/claude/versions/2.0.67 /home/node/.local/bin/claude
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy app source
|
||||
COPY . .
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Switch to node user
|
||||
USER node
|
||||
|
||||
# Set PATH to include claude
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
17
backend/package.json
Normal file
17
backend/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "claude-web-ui-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "WebSocket backend for Claude Code JSON streaming",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2",
|
||||
"cors": "^2.8.5",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
298
backend/server.js
Normal file
298
backend/server.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { spawn } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import cors from 'cors';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// Store active Claude sessions
|
||||
const sessions = new Map();
|
||||
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// REST endpoint to list available projects
|
||||
app.get('/api/projects', (req, res) => {
|
||||
const baseDirs = [
|
||||
{ path: '/projects', name: 'projects', description: 'Development projects' },
|
||||
{ path: '/docker', name: 'docker', description: 'Docker configurations' },
|
||||
{ path: '/stacks', name: 'stacks', description: 'Production stacks' }
|
||||
];
|
||||
|
||||
const projects = [];
|
||||
for (const dir of baseDirs) {
|
||||
if (existsSync(dir.path)) {
|
||||
projects.push({ ...dir, type: 'directory' });
|
||||
}
|
||||
}
|
||||
res.json(projects);
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
activeSessions: sessions.size,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Get session history for a project
|
||||
app.get('/api/history/:project', (req, res) => {
|
||||
try {
|
||||
const projectPath = decodeURIComponent(req.params.project);
|
||||
// Convert project path to Claude's folder naming convention
|
||||
const projectFolder = projectPath.replace(/\//g, '-');
|
||||
const historyDir = `/home/node/.claude/projects/${projectFolder}`;
|
||||
|
||||
if (!existsSync(historyDir)) {
|
||||
return res.json({ messages: [], sessionId: null });
|
||||
}
|
||||
|
||||
// Find the most recent non-agent session file
|
||||
const files = readdirSync(historyDir)
|
||||
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(historyDir, f),
|
||||
mtime: statSync(join(historyDir, f)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
return res.json({ messages: [], sessionId: null });
|
||||
}
|
||||
|
||||
const latestFile = files[0];
|
||||
const sessionId = latestFile.name.replace('.jsonl', '');
|
||||
const content = readFileSync(latestFile.path, 'utf-8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
const messages = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
// Parse user messages
|
||||
if (event.type === 'user' && event.message?.content) {
|
||||
const textContent = event.message.content
|
||||
.filter(c => c.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join('');
|
||||
if (textContent && !event.tool_use_result) {
|
||||
messages.push({
|
||||
type: 'user',
|
||||
content: textContent,
|
||||
timestamp: event.timestamp || Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse assistant messages
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
messages.push({
|
||||
type: 'assistant',
|
||||
content: block.text,
|
||||
timestamp: event.timestamp || Date.now()
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
toolUseId: block.id,
|
||||
timestamp: event.timestamp || Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tool results
|
||||
if (event.type === 'user' && event.tool_use_result) {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
content: event.tool_use_result.content,
|
||||
toolUseId: event.tool_use_result.tool_use_id,
|
||||
isError: event.tool_use_result.is_error || false,
|
||||
timestamp: event.timestamp || Date.now()
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ messages, sessionId });
|
||||
} catch (err) {
|
||||
console.error('Error reading history:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const sessionId = uuidv4();
|
||||
console.log(`[${sessionId}] New WebSocket connection`);
|
||||
|
||||
let claudeProcess = null;
|
||||
let currentProject = null;
|
||||
|
||||
const sendToClient = (type, data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
|
||||
}
|
||||
};
|
||||
|
||||
const startClaudeSession = (projectPath, resume = true) => {
|
||||
if (claudeProcess) {
|
||||
console.log(`[${sessionId}] Killing existing Claude process`);
|
||||
claudeProcess.kill();
|
||||
}
|
||||
|
||||
currentProject = projectPath;
|
||||
console.log(`[${sessionId}] Starting Claude in: ${projectPath} (resume: ${resume})`);
|
||||
|
||||
const args = [
|
||||
'-p',
|
||||
'--output-format', 'stream-json',
|
||||
'--input-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions'
|
||||
];
|
||||
|
||||
// Add continue flag to resume most recent conversation
|
||||
if (resume) {
|
||||
args.push('--continue');
|
||||
}
|
||||
|
||||
claudeProcess = spawn('claude', args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: projectPath || '/projects',
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath });
|
||||
|
||||
sendToClient('session_started', {
|
||||
sessionId,
|
||||
project: projectPath
|
||||
});
|
||||
|
||||
// Handle stdout (JSON events)
|
||||
let buffer = '';
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200));
|
||||
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
console.log(`[${sessionId}] Event type:`, event.type);
|
||||
sendToClient('claude_event', { event });
|
||||
} catch (e) {
|
||||
// Non-JSON output, send as raw
|
||||
console.log(`[${sessionId}] Raw output:`, line.substring(0, 100));
|
||||
sendToClient('raw_output', { content: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
const content = data.toString();
|
||||
console.log(`[${sessionId}] stderr:`, content);
|
||||
sendToClient('stderr', { content });
|
||||
});
|
||||
|
||||
claudeProcess.on('close', (code) => {
|
||||
console.log(`[${sessionId}] Claude process exited with code ${code}`);
|
||||
sendToClient('session_ended', { code });
|
||||
sessions.delete(sessionId);
|
||||
claudeProcess = null;
|
||||
});
|
||||
|
||||
claudeProcess.on('error', (err) => {
|
||||
console.error(`[${sessionId}] Claude process error:`, err);
|
||||
sendToClient('error', { message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
console.log(`[${sessionId}] Received:`, data.type);
|
||||
|
||||
switch (data.type) {
|
||||
case 'start_session':
|
||||
startClaudeSession(data.project || '/projects', data.resume !== false);
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
if (!claudeProcess) {
|
||||
sendToClient('error', { message: 'No active Claude session' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: data.message }]
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...');
|
||||
claudeProcess.stdin.write(JSON.stringify(payload) + '\n');
|
||||
break;
|
||||
|
||||
case 'stop_session':
|
||||
if (claudeProcess) {
|
||||
claudeProcess.kill();
|
||||
claudeProcess = null;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`[${sessionId}] Unknown message type:`, data.type);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${sessionId}] Error processing message:`, e);
|
||||
sendToClient('error', { message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[${sessionId}] WebSocket closed`);
|
||||
if (claudeProcess) {
|
||||
claudeProcess.kill();
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`[${sessionId}] WebSocket error:`, err);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
|
||||
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user