diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eb00f29..35ccc7a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import './App.css'; const App = () => { @@ -6,12 +6,10 @@ const App = () => { const [selectedTask, setSelectedTask] = useState(null); const [showForm, setShowForm] = useState(false); const [formData, setFormData] = useState({ - name: '', - description: '', - prompt: '', - schedule_type: 'manual', - schedule_value: '', - enabled: true + name: '', description: '', prompt: '', schedule_type: 'manual', + schedule_value: '', enabled: true, + agent_model: '', agent_tools: 'Bash,Read,Write,Edit', + agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300 }); const [systemInfo, setSystemInfo] = useState(null); const [usageStats, setUsageStats] = useState(null); @@ -20,403 +18,296 @@ const App = () => { const [showAddMcp, setShowAddMcp] = useState(false); const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' }); const [loading, setLoading] = useState(false); - const [activeView, setActiveView] = useState('tasks'); + const [activeView, setActiveView] = useState('chat'); // 'chat' | 'tasks' | 'dashboard' - // Auth token state + // Auth const [authToken, setAuthToken] = useState(''); const [tokenType, setTokenType] = useState('oauth_token'); const [tokenSubmitting, setTokenSubmitting] = useState(false); + // Chat + const [chatMessages, setChatMessages] = useState([]); + const [chatInput, setChatInput] = useState(''); + const [chatSending, setChatSending] = useState(false); + const [chatSessionId, setChatSessionId] = useState(() => crypto.randomUUID ? crypto.randomUUID() : Date.now().toString()); + const [chatSessions, setChatSessions] = useState([]); + const [chatModel, setChatModel] = useState(''); + const chatEndRef = useRef(null); + const wsRef = useRef(null); + const [wsConnected, setWsConnected] = useState(false); + useEffect(() => { - fetchTasks(); - fetchSystemInfo(); - fetchUsageStats(); - fetchAuthStatus(); - fetchMcpServers(); + fetchTasks(); fetchSystemInfo(); fetchUsageStats(); fetchAuthStatus(); fetchMcpServers(); const interval = setInterval(() => { - fetchTasks(); - fetchSystemInfo(); - fetchUsageStats(); - fetchAuthStatus(); - fetchMcpServers(); + fetchTasks(); fetchSystemInfo(); fetchAuthStatus(); }, 10000); return () => clearInterval(interval); }, []); - const fetchTasks = async () => { - try { - const response = await fetch('/api/tasks'); - const data = await response.json(); - setTasks(data); - } catch (error) { - console.error('Error fetching tasks:', error); + useEffect(() => { + if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + }, [chatMessages]); + + // WebSocket for chat + const connectWs = useCallback(() => { + if (wsRef.current && wsRef.current.readyState <= 1) return; + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${proto}//${window.location.host}/api/chat/ws/${chatSessionId}`); + ws.onopen = () => setWsConnected(true); + ws.onclose = () => setWsConnected(false); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'chunk') { + setChatMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && last.streaming) { + return [...prev.slice(0, -1), { ...last, content: last.content + data.content }]; + } + return [...prev, { role: 'assistant', content: data.content, streaming: true }]; + }); + } else if (data.type === 'done') { + setChatMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && last.streaming) { + return [...prev.slice(0, -1), { ...last, streaming: false }]; + } + return prev; + }); + setChatSending(false); + } else if (data.type === 'error') { + setChatMessages(prev => [...prev, { role: 'error', content: data.content }]); + setChatSending(false); + } else if (data.type === 'status' && data.content === 'thinking') { + setChatMessages(prev => [...prev, { role: 'assistant', content: '', streaming: true }]); + } + }; + wsRef.current = ws; + }, [chatSessionId]); + + useEffect(() => { + if (activeView === 'chat') connectWs(); + return () => { + if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } + }; + }, [activeView, chatSessionId, connectWs]); + + const sendChatMessage = async () => { + if (!chatInput.trim() || chatSending) return; + const msg = chatInput.trim(); + setChatInput(''); + setChatMessages(prev => [...prev, { role: 'user', content: msg }]); + setChatSending(true); + + if (wsRef.current && wsRef.current.readyState === 1) { + wsRef.current.send(JSON.stringify({ + message: msg, model: chatModel || undefined + })); + } else { + // Fallback to HTTP + try { + const response = await fetch('/api/chat/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: msg, session_id: chatSessionId, model: chatModel || undefined + }) + }); + const data = await response.json(); + setChatMessages(prev => [...prev, { + role: data.status === 'ok' ? 'assistant' : 'error', + content: data.response + }]); + } catch (error) { + setChatMessages(prev => [...prev, { role: 'error', content: 'Failed to send message' }]); + } + setChatSending(false); } }; - const fetchSystemInfo = async () => { + const startNewChat = () => { + setChatMessages([]); + const newId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(); + setChatSessionId(newId); + if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } + }; + + const loadChatSession = async (sid) => { + setChatSessionId(sid); + if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } try { - const response = await fetch('/api/system/info'); + const response = await fetch(`/api/chat/history/${sid}`); const data = await response.json(); - setSystemInfo(data); - } catch (error) { - console.error('Error fetching system info:', error); + setChatMessages(data.map(m => ({ role: m.role, content: m.content }))); + } catch (e) { + console.error('Error loading chat session:', e); } }; - const fetchUsageStats = async () => { - try { - const response = await fetch('/api/system/usage'); - const data = await response.json(); - setUsageStats(data); - } catch (error) { - console.error('Error fetching usage stats:', error); - } - }; - - const fetchAuthStatus = async () => { - try { - const response = await fetch('/api/auth/status'); - const data = await response.json(); - setAuthStatus(data); - } catch (error) { - console.error('Error fetching auth status:', error); - } - }; + // Fetchers + const fetchTasks = async () => { try { const r = await fetch('/api/tasks'); setTasks(await r.json()); } catch(e) {} }; + const fetchSystemInfo = async () => { try { const r = await fetch('/api/system/info'); setSystemInfo(await r.json()); } catch(e) {} }; + const fetchUsageStats = async () => { try { const r = await fetch('/api/system/usage'); setUsageStats(await r.json()); } catch(e) {} }; + const fetchAuthStatus = async () => { try { const r = await fetch('/api/auth/status'); setAuthStatus(await r.json()); } catch(e) {} }; + const fetchMcpServers = async () => { try { const r = await fetch('/api/mcp/servers'); setMcpServers(await r.json()); } catch(e) {} }; + const fetchChatSessions = async () => { try { const r = await fetch('/api/chat/sessions'); setChatSessions(await r.json()); } catch(e) {} }; const handleSubmitToken = async (e) => { e.preventDefault(); if (!authToken.trim()) return; setTokenSubmitting(true); try { - const response = await fetch('/api/auth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const r = await fetch('/api/auth/token', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: authToken.trim(), token_type: tokenType }) }); - const data = await response.json(); - if (data.status === 'logged_in') { - setAuthToken(''); - fetchAuthStatus(); - } else if (data.status === 'token_saved') { - setAuthToken(''); - fetchAuthStatus(); - alert(data.message); - } else { - alert(data.message || 'Token submission failed'); - } - } catch (error) { - console.error('Error submitting token:', error); - alert('Failed to submit token'); - } + const data = await r.json(); + setAuthToken(''); + fetchAuthStatus(); + if (data.status !== 'logged_in') alert(data.message); + } catch(e) { alert('Failed to submit token'); } setTokenSubmitting(false); }; - const handleLogout = async () => { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - fetchAuthStatus(); - } catch (error) { - console.error('Error logging out:', error); - } - }; - - const fetchMcpServers = async () => { - try { - const response = await fetch('/api/mcp/servers'); - const data = await response.json(); - setMcpServers(data); - } catch (error) { - console.error('Error fetching MCP servers:', error); - } - }; + const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); fetchAuthStatus(); }; const handleAddMcp = async (e) => { e.preventDefault(); - try { - const response = await fetch('/api/mcp/servers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mcpForm) - }); - const data = await response.json(); - if (data.status === 'ok') { - setShowAddMcp(false); - setMcpForm({ name: '', server_type: 'sse', url: '', command: '' }); - fetchMcpServers(); - } else { - alert(data.message || 'Failed to add MCP server'); - } - } catch (error) { - console.error('Error adding MCP server:', error); - } - }; - - const handleRemoveMcp = async (name) => { - if (!confirm(`Remove MCP server "${name}"?`)) return; - try { - await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' }); - fetchMcpServers(); - } catch (error) { - console.error('Error removing MCP server:', error); - } + const r = await fetch('/api/mcp/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mcpForm) }); + const data = await r.json(); + if (data.status === 'ok') { setShowAddMcp(false); setMcpForm({ name: '', server_type: 'sse', url: '', command: '' }); fetchMcpServers(); } + else alert(data.message || 'Failed'); }; + const handleRemoveMcp = async (name) => { if (!confirm(`Remove "${name}"?`)) return; await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' }); fetchMcpServers(); }; const handleCreateTask = async (e) => { - e.preventDefault(); - setLoading(true); + e.preventDefault(); setLoading(true); try { - const response = await fetch('/api/tasks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }); - if (response.ok) { - await fetchTasks(); - setShowForm(false); - setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true }); + const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); + if (r.ok) { + await fetchTasks(); setShowForm(false); + setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true, + agent_model: '', agent_tools: 'Bash,Read,Write,Edit', agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300 }); } - } catch (error) { - console.error('Error creating task:', error); - } + } catch(e) {} setLoading(false); }; - const handleRunTask = async (taskId) => { - try { - await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' }); - await fetchTasks(); - } catch (error) { - console.error('Error running task:', error); - } - }; - + const handleRunTask = async (taskId) => { await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' }); await fetchTasks(); }; const handleDeleteTask = async (taskId) => { - if (confirm('Delete this task?')) { - try { - await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); - await fetchTasks(); - setSelectedTask(null); - } catch (error) { - console.error('Error deleting task:', error); - } - } + if (confirm('Delete this task?')) { await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); await fetchTasks(); setSelectedTask(null); } }; - const getStatusColor = (status) => { - switch (status) { - case 'running': return '#3498db'; - case 'completed': return '#27ae60'; - case 'failed': return '#e74c3c'; - default: return '#95a5a6'; - } - }; + const getStatusColor = (s) => ({ running: '#3498db', completed: '#27ae60', failed: '#e74c3c' }[s] || '#95a5a6'); return (

Claude Persistent Agent

-

Scheduled task management & Claude Code runner

+

Chat, schedule tasks & orchestrate agents

{authStatus?.status === 'logged_in' ? ( - - ● {authStatus.account || `Authenticated (${authStatus.auth_method || 'token'})`} - + ● {authStatus.account || 'Authenticated'} ) : authStatus?.has_saved_token ? ( - - ● Token saved ({authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth'}) - + ● Token saved ) : ( - setActiveView('dashboard')} style={{cursor:'pointer'}}> - ⚠ Not authenticated - + setActiveView('dashboard')} style={{cursor:'pointer'}}>⚠ Not authenticated )}
{systemInfo && (
- {systemInfo.task_count} tasks · {systemInfo.total_runs || 0} runs + {systemInfo.task_count} agents · {systemInfo.total_runs || 0} runs
)}
- {activeView === 'dashboard' && ( -
-

System Dashboard

-
- {systemInfo && ( - <> -
📋
{systemInfo.task_count}
Total Tasks
-
{systemInfo.completed_runs || 0}
Completed Runs
-
{systemInfo.failed_runs || 0}
Failed Runs
-
{systemInfo.running_runs || 0}
Currently Running
-
🔄
{systemInfo.total_runs || 0}
Total Runs
-
{systemInfo.scheduler_running ? '🟢' : '🔴'}
{systemInfo.scheduler_running ? 'Active' : 'Stopped'}
Scheduler
- - )} -
- {/* Auth Section */} -
-

Claude Authentication

-
- {authStatus?.status === 'logged_in' ? ( -
- -
-
Connected to Claude
-
{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}
-
- + {/* ===== CHAT VIEW ===== */} + {activeView === 'chat' && ( +
+
+ +
+ +
+
+ + {chatSessions.map(s => ( +
loadChatSession(s.session_id)}> +
{(s.first_message || 'Chat').substring(0, 40)}
+
{s.message_count} msgs · {new Date(s.last_message).toLocaleDateString()}
- ) : ( -
-
- 🔐 -
-
{authStatus?.has_saved_token ? 'Token Saved — Update or Replace' : 'Authenticate with Token'}
-
Two options to authenticate:
-
-
- -
-
-
Option 1: Setup Token (Claude Max/Pro)
-
Run this on TrueNAS to generate a long-lived token:
- docker exec -it claude-persistent-agent claude setup-token -
Then paste the token (starts with sk-ant-oat) below.
-
-
-
Option 2: API Key (Console billing)
-
- Get an API key from console.anthropic.com and paste it below. -
-
-
- -
-
- - -
-
- setAuthToken(e.target.value)} className="auth-token-input" autoComplete="off" /> - -
-
- - {authStatus?.has_saved_token && ( -
- Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'} - -
- )} -
- )} + ))} +
+
+ + {wsConnected ? 'Connected' : 'Disconnected'}
- - {/* MCP Section */} -
-

MCP Servers

-
- {mcpServers?.servers?.length > 0 ? ( -
- {mcpServers.servers.map((srv, i) => ( -
- - {srv.name || srv} - {srv.details || srv.url || srv.type || ''} - -
- ))} -
- ) : ( -
-

No MCP servers configured.

- {mcpServers?.raw &&
{mcpServers.raw}
} +
+
+ {chatMessages.length === 0 && ( +
+
💬
+

Chat with Claude

+

Send a message to start a conversation with the Claude Code instance running on your server.

+

Claude has access to Bash, file system, and any MCP servers you've configured.

)} - {showAddMcp ? ( -
-
- setMcpForm({...mcpForm, name: e.target.value})} required /> - + {chatMessages.map((msg, i) => ( +
+
+ {msg.role === 'user' ? '👤' : msg.role === 'error' ? '⚠️' : '🤖'}
- {mcpForm.server_type === 'sse' ? ( - setMcpForm({...mcpForm, url: e.target.value})} required /> - ) : ( - setMcpForm({...mcpForm, command: e.target.value})} required /> - )} -
- - +
+
{msg.content}{msg.streaming ? '▊' : ''}
- - ) : ( - - )} +
+ ))} +
-
- - {/* Usage Section */} -
-

Claude API Usage

-
- {usageStats ? ( - <> -
Claude Runs (all time){usageStats.claude_runs_total ?? '—'}
-
Active Sessions{usageStats.session_count ?? '—'}
-
First Run{usageStats.first_run ? new Date(usageStats.first_run).toLocaleString() : '—'}
-
Last Run{usageStats.last_run ? new Date(usageStats.last_run).toLocaleString() : '—'}
-
🔄 Next Monthly Reset{usageStats.next_reset ? new Date(usageStats.next_reset).toLocaleDateString() : '—'}
-
⏳ Days Until Reset{usageStats.days_until_reset ?? '—'}
- - ) : ( -
Loading usage stats...
- )} -
-
- - {/* Task Breakdown */} -
-

Task Breakdown

-
- {tasks.length === 0 ? ( -

No tasks yet. Create one in the Tasks view.

- ) : ( - tasks.map(t => ( -
- {t.name} - {t.schedule_type} - {t.status} - {t.last_run ? new Date(t.last_run).toLocaleString() : 'never'} -
- )) - )} +
+