feat(opax-ui): make Emma a modern operator workspace
This commit is contained in:
parent
8f1c501651
commit
c646649a76
|
|
@ -101,4 +101,13 @@ router.get('/capabilities', (req, res) => {
|
|||
res.json(capabilities);
|
||||
});
|
||||
|
||||
router.get('/emma/status', async (req, res) => {
|
||||
try {
|
||||
await callAgent('get_health', {});
|
||||
res.json({ status: 'ready', label: 'Emma Local', message: 'Emma is ready.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ status: 'unavailable', label: 'Emma Local', message: 'Emma is unavailable.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const AgentConsoleView = () => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [transcript, setTranscript] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [ticketNumber, setTicketNumber] = useState(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
|
||||
const newMessage = { role: 'user', content: message };
|
||||
setTranscript(prev => [...prev, newMessage]);
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/console/emma/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Emma is unavailable.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const emmaMessage = { role: 'assistant', content: data.reply };
|
||||
setTranscript(prev => [...prev, emmaMessage]);
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="console-card">
|
||||
<h2>Emma Local</h2>
|
||||
<div className="agent-info">
|
||||
<span>Local model · server-side memory enabled</span>
|
||||
{ticketNumber && <span>Working context: Gitea #{ticketNumber}</span>}
|
||||
</div>
|
||||
<div className="transcript">
|
||||
{transcript.map((msg, index) => (
|
||||
<div key={index} className={`message ${msg.role}`}>
|
||||
<strong>{msg.role}: </strong>{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="message assistant">...</div>}
|
||||
{error && <div className="message error">{error}</div>}
|
||||
</div>
|
||||
<div className="chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Chat with Emma..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<button onClick={handleSend} disabled={loading}>
|
||||
{loading ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentConsoleView;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const ApprovalsView = () => {
|
||||
return (
|
||||
<div className="console-card">
|
||||
<h2>Approvals</h2>
|
||||
<p className="unavailable-message">No approval read model configured yet.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalsView;
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Capability {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Capabilities {
|
||||
[key: string]: Capability;
|
||||
}
|
||||
|
||||
const CapabilityPanel = () => {
|
||||
const [capabilities, setCapabilities] = useState({});
|
||||
const [capabilities, setCapabilities] = useState<Capabilities>({});
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCapabilities = async () => {
|
||||
|
|
@ -13,35 +22,36 @@ const CapabilityPanel = () => {
|
|||
}, []);
|
||||
|
||||
const capabilityGroups = {
|
||||
'Platform Status': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
|
||||
'Git and Gitea': ['list_commits', 'get_file'],
|
||||
'Tickets and Work Queue': [],
|
||||
'Code Agents': ['run_emma'],
|
||||
'Tests and Validation': [],
|
||||
'Builds and Deployments': ['trigger_build', 'commit_and_push_files'],
|
||||
'Cloud Run and Infrastructure': [],
|
||||
'Logs and Telemetry': [],
|
||||
'Billing and CostGuard': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
|
||||
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
|
||||
'Platform': ['get_health', 'get_build_status', 'get_state', 'get_telemetry'],
|
||||
'Repository': ['list_commits', 'get_file'],
|
||||
'Work and tickets': [],
|
||||
'Code agents': ['run_emma'],
|
||||
'Validation': [],
|
||||
'Deployment': ['trigger_build', 'commit_and_push_files'],
|
||||
'Costs': ['get_billing_summary', 'get_billing_forecast', 'get_billing_anomalies', 'get_billing_budget', 'set_billing_budget'],
|
||||
'Notifications': ['send_email', 'send_sms', 'send_webhook'],
|
||||
'Customers and Workspace': ['list_customers', 'list_workspace_users', 'create_invite'],
|
||||
'Memory and Knowledge': [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="capability-panel">
|
||||
<h3>Capabilities</h3>
|
||||
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
|
||||
<div key={group} className="capability-group">
|
||||
<h4>{group}</h4>
|
||||
<ul>
|
||||
{capabilityKeys.map(key => {
|
||||
const capability = capabilities[key];
|
||||
if (!capability) return null;
|
||||
return <li key={key} className={capability.type}>{key}</li>
|
||||
})}
|
||||
</ul>
|
||||
<div className={`capability-drawer ${isOpen ? 'open' : ''}`}>
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="drawer-toggle">Capabilities</button>
|
||||
<div className="drawer-content">
|
||||
<h3>Capabilities</h3>
|
||||
{Object.entries(capabilityGroups).map(([group, capabilityKeys]) => (
|
||||
<div key={group} className="capability-group">
|
||||
<h4>{group}</h4>
|
||||
<ul>
|
||||
{capabilityKeys.map(key => {
|
||||
const capability = capabilities[key];
|
||||
if (!capability) return null;
|
||||
return <li key={key} className={capability.type}>{key.replace(/_/g, ' ')}</li>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
105
opax-web/frontend/src/components/ChatWorkspace.tsx
Normal file
105
opax-web/frontend/src/components/ChatWorkspace.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Message {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const ChatWorkspace = () => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [transcript, setTranscript] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emmaStatus, setEmmaStatus] = useState<string>('unavailable');
|
||||
const [ticketNumber, setTicketNumber] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEmmaStatus = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/console/emma/status', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
setEmmaStatus(data.status);
|
||||
} catch (e) {
|
||||
setEmmaStatus('unavailable');
|
||||
}
|
||||
};
|
||||
fetchEmmaStatus();
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
|
||||
const newMessage: Message = { role: 'user', content: message };
|
||||
setTranscript(prev => [...prev, newMessage]);
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/console/emma/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ message, ticket_number: ticketNumber }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Emma is unavailable.');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const emmaMessage: Message = { role: 'assistant', content: data.reply };
|
||||
setTranscript(prev => [...prev, emmaMessage]);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-workspace">
|
||||
<div className="chat-header">
|
||||
<h3>Emma Local</h3>
|
||||
<span>{emmaStatus === 'ready' ? 'Connected' : 'Unavailable'}</span>
|
||||
{ticketNumber && <span className="ticket-chip">Gitea #{ticketNumber}</span>}
|
||||
</div>
|
||||
<div className="transcript">
|
||||
{transcript.length === 0 && !loading && (
|
||||
<div className="welcome-message">
|
||||
<p>Hei Chris. Hva vil du at jeg skal få gjort?</p>
|
||||
<div className="quick-prompts">
|
||||
<button>Status på plattformen</button>
|
||||
<button>Hva er neste oppgave?</button>
|
||||
<button>Arbeid med Gitea #1</button>
|
||||
<button>Sjekk siste build</button>
|
||||
<button>Vis kostnadsavvik</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{transcript.map((msg, index) => (
|
||||
<div key={index} className={`message ${msg.role}`}>
|
||||
<strong>{msg.role}: </strong>{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="message assistant">...</div>}
|
||||
{error && <div className="message error">{error} <button onClick={handleSend}>Retry</button></div>}
|
||||
</div>
|
||||
<div className="chat-composer">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder="Chat with Emma..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<button onClick={handleSend} disabled={loading}>
|
||||
{loading ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWorkspace;
|
||||
28
opax-web/frontend/src/components/ContextPanel.tsx
Normal file
28
opax-web/frontend/src/components/ContextPanel.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import CapabilityPanel from './CapabilityPanel';
|
||||
|
||||
const ContextPanel = () => {
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="current-task">
|
||||
<h4>Current task</h4>
|
||||
<div>Idle</div>
|
||||
</div>
|
||||
<div className="ticket-context">
|
||||
<h4>Ticket context</h4>
|
||||
<div>No active ticket</div>
|
||||
</div>
|
||||
<div className="suggested-actions">
|
||||
<h4>Suggested actions</h4>
|
||||
<ul>
|
||||
<li>Check platform</li>
|
||||
<li>Review Gitea #1</li>
|
||||
<li>Check last deployment</li>
|
||||
</ul>
|
||||
</div>
|
||||
<CapabilityPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextPanel;
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
type ConsoleStatus = 'success' | 'unavailable' | 'loading';
|
||||
|
||||
type SafeHealthSummary = {
|
||||
status: 'ok';
|
||||
service: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
type ConsoleStatusItem = {
|
||||
status: Exclude<ConsoleStatus, 'loading'>;
|
||||
summary: SafeHealthSummary | null;
|
||||
message: 'Available' | 'Unavailable';
|
||||
};
|
||||
|
||||
interface ConsoleStatusResponse {
|
||||
health?: ConsoleStatusItem;
|
||||
buildStatus?: ConsoleStatusItem;
|
||||
platformState?: ConsoleStatusItem;
|
||||
}
|
||||
|
||||
interface StatusBlockProps {
|
||||
title: string;
|
||||
status: ConsoleStatus;
|
||||
summary: SafeHealthSummary | null;
|
||||
}
|
||||
|
||||
const StatusBlock: React.FC<StatusBlockProps> = ({ title, status, summary }) => {
|
||||
return (
|
||||
<div className={`status-block status-${status}`}>
|
||||
<h3>{title}</h3>
|
||||
{status === 'loading' && <p>Loading...</p>}
|
||||
{status === 'unavailable' && <p>Unavailable</p>}
|
||||
{status === 'success' && summary && (
|
||||
<ul>
|
||||
<li><strong>Status:</strong> {summary.status}</li>
|
||||
<li><strong>Service:</strong> {summary.service}</li>
|
||||
<li><strong>Version:</strong> {summary.version}</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlatformStatusProps {
|
||||
statusData: ConsoleStatusResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const unavailableStatus: ConsoleStatus = 'unavailable';
|
||||
|
||||
const PlatformStatus: React.FC<PlatformStatusProps> = ({ statusData, loading }) => {
|
||||
return (
|
||||
<div className="console-card">
|
||||
<h2>Platform Overview</h2>
|
||||
<div className="status-container">
|
||||
<StatusBlock
|
||||
title="Health"
|
||||
status={loading ? 'loading' : statusData?.health?.status ?? unavailableStatus}
|
||||
summary={statusData?.health?.summary ?? null}
|
||||
/>
|
||||
<StatusBlock
|
||||
title="Latest Build"
|
||||
status={loading ? 'loading' : statusData?.buildStatus?.status ?? unavailableStatus}
|
||||
summary={null} // Schema not confirmed, so always null for now
|
||||
/>
|
||||
<StatusBlock
|
||||
title="Platform State"
|
||||
status={loading ? 'loading' : statusData?.platformState?.status ?? unavailableStatus}
|
||||
summary={null} // Schema not confirmed, so always null for now
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStatus;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const ProjectRegistryView = () => {
|
||||
return (
|
||||
<div className="console-card">
|
||||
<h2>Projects</h2>
|
||||
<p className="unavailable-message">No project registry configured yet.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectRegistryView;
|
||||
26
opax-web/frontend/src/components/Sidebar.tsx
Normal file
26
opax-web/frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="logo">OPAX</div>
|
||||
<div className="emma-status">Emma Local: Connected</div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li className="active">Chat</li>
|
||||
<li>Work</li>
|
||||
<li>Repository</li>
|
||||
<li>Deployments</li>
|
||||
<li>Costs</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="ticket-context">
|
||||
<div>Current selected ticket:</div>
|
||||
<div>No active task</div>
|
||||
</div>
|
||||
<div className="profile">User Profile</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const WorkQueueView = () => {
|
||||
return (
|
||||
<div className="console-card">
|
||||
<h2>Work Queue</h2>
|
||||
<p className="unavailable-message">No ticket read model configured yet.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkQueueView;
|
||||
|
|
@ -1,138 +1,14 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import PlatformStatus from '../components/PlatformStatus';
|
||||
import ProjectRegistryView from '../components/ProjectRegistryView';
|
||||
import WorkQueueView from '../components/WorkQueueView';
|
||||
import ApprovalsView from '../components/ApprovalsView';
|
||||
import AgentConsoleView from '../components/AgentConsoleView';
|
||||
import CapabilityPanel from '../components/CapabilityPanel';
|
||||
import React from 'react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import ChatWorkspace from '../components/ChatWorkspace';
|
||||
import ContextPanel from '../components/ContextPanel';
|
||||
|
||||
// --- Types for Safe Data Handling ---
|
||||
type ConsoleStatus = 'success' | 'unavailable';
|
||||
|
||||
type SafeHealthSummary = {
|
||||
status: 'ok';
|
||||
service: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
type ConsoleStatusItem = {
|
||||
status: ConsoleStatus;
|
||||
summary: SafeHealthSummary | null;
|
||||
message: 'Available' | 'Unavailable';
|
||||
};
|
||||
|
||||
export interface ConsoleStatusResponse {
|
||||
health: ConsoleStatusItem;
|
||||
buildStatus: ConsoleStatusItem;
|
||||
platformState: ConsoleStatusItem;
|
||||
}
|
||||
|
||||
const unavailableItem: ConsoleStatusItem = {
|
||||
status: 'unavailable',
|
||||
summary: null,
|
||||
message: 'Unavailable',
|
||||
};
|
||||
|
||||
// --- Normalizer Function ---
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isHealthSummary(value: unknown): value is SafeHealthSummary {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.status === 'ok' &&
|
||||
typeof value.service === 'string' &&
|
||||
typeof value.version === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse {
|
||||
let health: ConsoleStatusItem = unavailableItem;
|
||||
|
||||
if (isRecord(raw) && isRecord(raw.health)) {
|
||||
const healthItem = raw.health;
|
||||
|
||||
if (
|
||||
healthItem.status === 'success' &&
|
||||
isHealthSummary(healthItem.summary)
|
||||
) {
|
||||
health = {
|
||||
status: 'success',
|
||||
summary: {
|
||||
status: 'ok',
|
||||
service: healthItem.summary.service,
|
||||
version: healthItem.summary.version,
|
||||
},
|
||||
message: 'Available',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
health,
|
||||
buildStatus: unavailableItem,
|
||||
platformState: unavailableItem,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
const OperationsConsole = () => {
|
||||
const [statusData, setStatusData] = useState<ConsoleStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefreshed, setLastRefreshed] = useState<string>('');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/console/status', { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
// Handle auth errors or server errors safely
|
||||
if (res.status === 401) {
|
||||
throw new Error('Access denied. Please log in again.');
|
||||
} else {
|
||||
throw new Error('Console data is unavailable.');
|
||||
}
|
||||
}
|
||||
const rawData = await res.json();
|
||||
setStatusData(normalizeStatusResponse(rawData));
|
||||
setLastRefreshed(new Date().toLocaleTimeString());
|
||||
} catch {
|
||||
setError('Console data is unavailable. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return (
|
||||
<div className="console-page">
|
||||
<div className="page-header">
|
||||
<h1>OPAX Operations Console</h1>
|
||||
<div className="header-actions">
|
||||
<span>Last refreshed: {lastRefreshed || 'never'}</span>
|
||||
<button onClick={fetchData} disabled={loading} className="primary">
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<PlatformStatus statusData={statusData} loading={loading} />
|
||||
<ProjectRegistryView />
|
||||
<WorkQueueView />
|
||||
<ApprovalsView />
|
||||
<AgentConsoleView />
|
||||
<CapabilityPanel />
|
||||
|
||||
<div className="console-layout">
|
||||
<Sidebar />
|
||||
<ChatWorkspace />
|
||||
<ContextPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user