feat(emma): deploy browser-based Emma Local operator POC

This commit is contained in:
Chris Christiansen 2026-09-13 15:53:04 +00:00
parent 0b40d556cd
commit 8f1c501651
7 changed files with 184 additions and 10 deletions

View File

@ -10,6 +10,7 @@ const READ_ONLY_TOOLS = new Set([
'get_build_status', 'get_build_status',
'get_state', 'get_state',
'get_telemetry', 'get_telemetry',
'run_emma',
]); ]);
export class AgentNotConfiguredError extends Error { export class AgentNotConfiguredError extends Error {

View File

@ -0,0 +1,27 @@
export const capabilities = {
get_health: { type: 'read' },
get_build_status: { type: 'read' },
get_state: { type: 'read' },
get_telemetry: { type: 'read' },
list_commits: { type: 'read' },
get_file: { type: 'read' },
list_customers: { type: 'read' },
get_billing_summary: { type: 'read' },
get_billing_forecast: { type: 'read' },
get_billing_anomalies: { type: 'read' },
get_billing_budget: { type: 'read' },
list_workspace_users: { type: 'read' },
list_user_aliases: { type: 'read' },
list_calendar_events: { type: 'read' },
run_emma: { type: 'read' },
trigger_build: { type: 'proposal' },
commit_and_push_files: { type: 'proposal' },
create_a2h2a_ticket: { type: 'proposal' },
send_email: { type: 'proposal' },
send_sms: { type: 'proposal' },
send_webhook: { type: 'proposal' },
create_invite: { type: 'proposal' },
create_email_alias: { type: 'proposal' },
delete_email_alias: { type: 'proposal' },
set_billing_budget: { type: 'proposal' },
};

View File

@ -67,4 +67,38 @@ router.get('/approvals', (req, res) => {
}); });
}); });
router.post('/emma/chat', async (req, res) => {
const { message, session_id, ticket_number } = req.body;
if (!message || typeof message !== 'string' || message.length > 4096) {
return res.status(400).json({ status: 'error', message: 'Invalid message.'});
}
if (ticket_number && (typeof ticket_number !== 'number' || !Number.isInteger(ticket_number) || ticket_number <= 0)) {
return res.status(400).json({ status: 'error', message: 'Invalid ticket number.'});
}
try {
const emmaData = await callAgent('run_emma', { prompt: message, history: [] });
res.json({
status: 'success',
reply: emmaData.response,
session_id,
ticket_number,
model_label: 'Emma Local',
message: 'Message processed by Emma.',
});
} catch (e) {
res.status(500).json({ status: 'error', message: 'Could not connect to Emma.' });
}
});
import { capabilities } from '../capabilityRegistry.js';
router.get('/capabilities', (req, res) => {
res.json(capabilities);
});
export default router; export default router;

View File

@ -43,6 +43,7 @@ const sm = session({
const io = new Server(http, { cors: { origin: front, credentials: true } }); const io = new Server(http, { cors: { origin: front, credentials: true } });
app.use(cors({ origin: front, credentials: true })); app.use(cors({ origin: front, credentials: true }));
app.use(express.json());
app.use(sm); app.use(sm);
app.use(express.static(frontendDistPath)); app.use(express.static(frontendDistPath));
io.use((s, n) => sm(s.request, {}, n)); io.use((s, n) => sm(s.request, {}, n));

View File

@ -1,13 +1,73 @@
import React from 'react'; import React, { useState } from 'react';
const AgentConsoleView = () => { 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 ( return (
<div className="console-card"> <div className="console-card">
<h2>Agent Console</h2> <h2>Emma Local</h2>
<div className="workflow-diagram"> <div className="agent-info">
<span>Draft</span> <span>Plan</span> <span>Scope Approval</span> <span>Implementation</span> <span>Diff Approval</span> <span>Validation</span> <span>Deployment Approval</span> <span>Deploy</span> <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>
<p className="unavailable-message">Ticket-bound agent execution is not configured yet.</p>
</div> </div>
); );
}; };

View File

@ -0,0 +1,49 @@
import React, { useState, useEffect } from 'react';
const CapabilityPanel = () => {
const [capabilities, setCapabilities] = useState({});
useEffect(() => {
const fetchCapabilities = async () => {
const res = await fetch('/api/console/capabilities');
const data = await res.json();
setCapabilities(data);
};
fetchCapabilities();
}, []);
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'],
'Notifications': ['send_email', 'send_sms', 'send_webhook'],
'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>
))}
</div>
);
};
export default CapabilityPanel;

View File

@ -4,6 +4,7 @@ import ProjectRegistryView from '../components/ProjectRegistryView';
import WorkQueueView from '../components/WorkQueueView'; import WorkQueueView from '../components/WorkQueueView';
import ApprovalsView from '../components/ApprovalsView'; import ApprovalsView from '../components/ApprovalsView';
import AgentConsoleView from '../components/AgentConsoleView'; import AgentConsoleView from '../components/AgentConsoleView';
import CapabilityPanel from '../components/CapabilityPanel';
// --- Types for Safe Data Handling --- // --- Types for Safe Data Handling ---
type ConsoleStatus = 'success' | 'unavailable'; type ConsoleStatus = 'success' | 'unavailable';
@ -130,6 +131,7 @@ const OperationsConsole = () => {
<WorkQueueView /> <WorkQueueView />
<ApprovalsView /> <ApprovalsView />
<AgentConsoleView /> <AgentConsoleView />
<CapabilityPanel />
</div> </div>
); );