From 8f1c5016513b8ad47014d3be7d5efe8c23461ed2 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 13 Sep 2026 15:53:04 +0000 Subject: [PATCH] feat(emma): deploy browser-based Emma Local operator POC --- opax-web/backend/agentClient.js | 1 + opax-web/backend/capabilityRegistry.js | 27 +++++++ opax-web/backend/routes/consoleApi.js | 34 ++++++++ opax-web/backend/server.js | 1 + .../src/components/AgentConsoleView.tsx | 80 ++++++++++++++++--- .../src/components/CapabilityPanel.tsx | 49 ++++++++++++ .../frontend/src/pages/OperationsConsole.tsx | 2 + 7 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 opax-web/backend/capabilityRegistry.js create mode 100644 opax-web/frontend/src/components/CapabilityPanel.tsx diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js index 1902a82..67d3d5c 100644 --- a/opax-web/backend/agentClient.js +++ b/opax-web/backend/agentClient.js @@ -10,6 +10,7 @@ const READ_ONLY_TOOLS = new Set([ 'get_build_status', 'get_state', 'get_telemetry', + 'run_emma', ]); export class AgentNotConfiguredError extends Error { diff --git a/opax-web/backend/capabilityRegistry.js b/opax-web/backend/capabilityRegistry.js new file mode 100644 index 0000000..db3470f --- /dev/null +++ b/opax-web/backend/capabilityRegistry.js @@ -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' }, +}; diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js index 9416c18..ca9129a 100644 --- a/opax-web/backend/routes/consoleApi.js +++ b/opax-web/backend/routes/consoleApi.js @@ -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; diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js index cf6556e..951be97 100644 --- a/opax-web/backend/server.js +++ b/opax-web/backend/server.js @@ -43,6 +43,7 @@ const sm = session({ const io = new Server(http, { cors: { origin: front, credentials: true } }); app.use(cors({ origin: front, credentials: true })); +app.use(express.json()); app.use(sm); app.use(express.static(frontendDistPath)); io.use((s, n) => sm(s.request, {}, n)); diff --git a/opax-web/frontend/src/components/AgentConsoleView.tsx b/opax-web/frontend/src/components/AgentConsoleView.tsx index b66c9c5..a38a591 100644 --- a/opax-web/frontend/src/components/AgentConsoleView.tsx +++ b/opax-web/frontend/src/components/AgentConsoleView.tsx @@ -1,15 +1,75 @@ -import React from 'react'; +import React, { useState } from 'react'; const AgentConsoleView = () => { - return ( -
-

Agent Console

-
- DraftPlanScope ApprovalImplementationDiff ApprovalValidationDeployment ApprovalDeploy -
-

Ticket-bound agent execution is not configured yet.

-
- ); + 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 ( +
+

Emma Local

+
+ Local model · server-side memory enabled + {ticketNumber && Working context: Gitea #{ticketNumber}} +
+
+ {transcript.map((msg, index) => ( +
+ {msg.role}: {msg.content} +
+ ))} + {loading &&
...
} + {error &&
{error}
} +
+
+ setMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSend()} + placeholder="Chat with Emma..." + disabled={loading} + /> + +
+
+ ); }; export default AgentConsoleView; diff --git a/opax-web/frontend/src/components/CapabilityPanel.tsx b/opax-web/frontend/src/components/CapabilityPanel.tsx new file mode 100644 index 0000000..3d6d30a --- /dev/null +++ b/opax-web/frontend/src/components/CapabilityPanel.tsx @@ -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 ( +
+

Capabilities

+ {Object.entries(capabilityGroups).map(([group, capabilityKeys]) => ( +
+

{group}

+
    + {capabilityKeys.map(key => { + const capability = capabilities[key]; + if (!capability) return null; + return
  • {key}
  • + })} +
+
+ ))} +
+ ); +}; + +export default CapabilityPanel; diff --git a/opax-web/frontend/src/pages/OperationsConsole.tsx b/opax-web/frontend/src/pages/OperationsConsole.tsx index 9983aef..6ccae6b 100644 --- a/opax-web/frontend/src/pages/OperationsConsole.tsx +++ b/opax-web/frontend/src/pages/OperationsConsole.tsx @@ -4,6 +4,7 @@ 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'; // --- Types for Safe Data Handling --- type ConsoleStatus = 'success' | 'unavailable'; @@ -130,6 +131,7 @@ const OperationsConsole = () => { + );