feat(emma): add conversation history and release tooling

This commit is contained in:
Chris Christiansen 2026-09-19 00:38:43 +00:00
parent 2a48141b99
commit aee859b3ba
14 changed files with 1261 additions and 272 deletions

View File

@ -0,0 +1,100 @@
# OPAX Live Deployment Runbook
## Purpose
Use these scripts to deploy the two production components required for the
Emma/OPAX experience:
- OPAX MCP: the conversational MCP backend and `run_emma` tool service.
- OPAX Web: the live web application served at `https://opax.vauco.no`.
## Fixed Production Targets
| Item | Value |
|---|---|
| Google Cloud project | `propane-will-491900-m5` |
| Region | `us-central1` |
| Artifact Registry repository | `us-central1-docker.pkg.dev/propane-will-491900-m5/osvauco-repo` |
| MCP Cloud Run service | `opax-mcp` |
| OPAX Web Cloud Run service | `opax-web` |
| Live operator URL | `https://opax.vauco.no` |
## Normal Release Sequence
Run commands from the repository root:
```bash
./scripts/deploy-mcp.sh
./scripts/deploy-opax-web.sh
./scripts/check-live-services.sh
```
The scripts build an image, resolve its immutable digest, and deploy that digest
to the existing Cloud Run service.
## MCP Deployment Behavior
The existing `cloudbuild.deploy.yaml` workflow builds and pushes an MCP image.
Its internal Cloud Build deploy step can fail because the Cloud Build service
account is blocked by VPC Service Controls.
`deploy-mcp.sh` handles that condition by resolving the image that was pushed
during the build and then deploying its immutable digest directly through the
authenticated local `gcloud` session.
A Cloud Build failure does not automatically mean the image build failed. The
script stops if it cannot resolve an immutable pushed image digest.
## Live Validation
After deployment:
1. Open `https://opax.vauco.no`.
2. Hard-refresh the browser with `Ctrl+Shift+R`.
3. Log in normally.
4. Confirm that the new Emma workspace UI is visible.
5. In a new conversation, send:
```text
Jeg heter Chris.
```
6. In the same conversation, send:
```text
Hva heter jeg?
```
7. Confirm that Emma uses the previous message as conversation history.
8. Start a second conversation and ask:
```text
Hva heter jeg?
```
9. Confirm that the second conversation does not inherit context from the first
conversation.
## Guardrails
- Use `us-central1` only.
- Never deploy to `europe-west1`.
- Never deploy a service named `opax`.
- Deploy only the existing `opax-mcp` and `opax-web` services.
- Final Cloud Run deployment must use an immutable image digest.
- Never use a mutable tag for the final deploy.
- Never print or place secret values in scripts, logs, documentation, or Git.
- Do not deploy from Gemini without explicit human approval.
- Do not change IAM, service accounts, secrets, VPC settings, DNS, OAuth, or
Cloud Run networking as part of a normal application release.
## Rollback
Use one of these existing rollback methods:
1. In Cloud Run, route traffic back to the prior ready revision.
2. Re-run the relevant deployment script after replacing the image digest with a
previously known good immutable digest.
Use `./scripts/check-live-services.sh` to record the currently active revisions
and images before a release.

View File

@ -47,4 +47,5 @@ class CanonicalEmma:
self._model,
prompt,
composed_system_prompt,
history=history or [],
)

View File

@ -0,0 +1,163 @@
# run_emma / emma.chat Component Manifest
**Status:** Draft<br>
**Runtime effect:** None<br>
**Governing standard:** [`INCU Master Constitution`](../../../docs/INCU_Master_Constitution.md)<br>
**Template:** [`INCU Component Manifest Template`](../../../docs/templates/component_manifest.md)<br>
**Review date:** 2026-10-18<br>
> This is a documentation-only draft manifest for an existing runtime
> capability. It does not activate, disable, expand, or otherwise modify the
> `run_emma` tool, the `emma.chat` capability, Emma's prompt, Emma's authority,
> A2H2A behavior, MCP behavior, or any external-system permission.
## Identity
- Component ID: emma.chat
- Component name: run_emma
- Class: Orchestrator
- Version: 0.1.0-draft
- Owner: Platform Engineering
- Status: draft
- Review date: 2026-10-18
Current runtime capability status:
ACTIVE in the existing capability registry.
Current manifest governance status:
draft.
Reason:
The manifest is documentation-only. No manifest validator or runtime
manifest-enforcement mechanism exists yet.
## Purpose
- Intended outcome: Provide conversational reasoning through the canonical Emma
runtime for a user-supplied prompt, while retaining the canonical system
prompt and appending only the read-only capability-status system context.
- Value to system: Provides a single canonical entry point for conversational
AI reasoning, decoupled from specific models or backends, and supplies
governance documentation for future manifest-driven orchestration.
- Explicit non-goals:
- No tool execution.
- No repository or file access.
- No deployment or infrastructure action.
- No external-system action, integration operation, or state-changing service
call. The existing model-inference request remains part of the current
canonical conversational runtime path only.
- No memory or document retrieval.
- No ticket creation, ticket approval, or A2H2A execution.
- No write, deletion, permission, scheduling, or communication action.
- No delegation to additional agents.
- No independent completion claim beyond the returned conversational response.
## Scope
- Permitted tasks:
- Accept a user-provided prompt through the existing canonical run_emma path.
- Compose the canonical Emma system prompt with read-only capability context.
- Return the model's conversational response.
- Prohibited tasks: Any task corresponding to a planned or forbidden
capability. This manifest does not activate any capability.
- Supported systems/environments: Existing opax-mcp runtime path.
- Time/cost/retry limits: Not currently declared in the runtime capability
registry. No additional retry, time, or cost authority is granted by this
manifest.
## Authority
- Read permissions: User-provided prompt and existing canonical
prompt/capability-context inputs within the current request path only.
- Write permissions: None.
- Approval requirement: No external or state-changing action is within scope.
Any future external or irreversible action requires a separate in-scope
manifest, exact explicit human approval, and platform authorization.
- Delegation rules: No delegation to subordinate agents, tools, connectors,
workflows, or operators is implemented or authorized.
- Revocation method: Disable or remove the existing runtime capability through
the authoritative runtime capability/tool configuration and platform
controls; this draft document alone does not revoke runtime access.
## Inputs and Outputs
- Required inputs: A user prompt supplied through the canonical run_emma
request path.
- Optional inputs: None declared for this manifest.
- Outputs: The existing raw conversational response returned by the canonical
Emma adapter/runtime path.
- Source of truth: Current capability registry for capability status; current
source code and tests for runtime contract; platform runtime for actual
service state.
- Evidence format: Existing unit-test output, capability-registry state,
adapter call contract, and runtime health/response evidence where separately
verified.
## INCU Mandate
- Outcome statement format: Return a bounded conversational response to a
user-supplied prompt without claiming unavailable authority.
- Startability rule: Begin only with the current user-provided prompt and the
existing read-only runtime path.
- Applicable levers: Interest, Novelty, Challenge, Urgency, Purpose — future
governance reference only; no runtime behavior change is introduced by this
manifest.
- Boundedness rule: One user prompt, current canonical runtime path, no
external-system action, no tool delegation, no state mutation, and no
authority expansion.
- Restart artifact: Not currently implemented for single-request conversational
handling.
- Blocker behavior: If a request requires a missing capability, tool, external
action, write, deployment, repository operation, memory/document access, or
unapproved delegation, state that it is unavailable and require a separate
approved manifest and explicit human authorization before any future
implementation.
## Safety and Governance
- Data classification: User-provided request content; classification and
retention are governed by existing platform policy and are not changed by
this manifest.
- Security constraints: Secrets must not be intentionally included in prompts
or outputs. This draft manifest does not implement secret detection or
redaction. No tool execution or external-system action is authorized, and
existing platform authentication and authorization remain controlling.
- Privacy constraints: Do not claim retention, deletion, or consent guarantees
not established by current implementation.
- Stop conditions: Missing capability, external or state-changing request,
request outside current conversational scope, missing approval for a future
consequential action, or evidence conflict.
- Escalation path: Accountable human owner / Platform Engineering.
- Audit events: No new INCU audit event is created by this draft manifest.
Existing runtime and A2H2A audit behavior remain unchanged.
## A2HA Contract
- Ticket fields read: None.
- Ticket fields written: None.
- Allowed state transitions: None.
- Required evidence before transition: Not applicable, because this component
has no A2HA write authority.
- Comment/update policy: No A2HA comment or update authority is granted.
## Evaluation
- Acceptance tests:
- Capability registry retains emma.chat as ACTIVE.
- Capability bridge remains deterministic and read-only.
- Canonical Emma adapter retains the original user prompt.
- Capability context is appended only to the system prompt.
- No tool, connector, external request, repository, deployment, memory,
document-retrieval, or state-changing capability is activated.
- Reliability metrics: Existing test success/failure and runtime health
evidence; no new metrics system is introduced.
- Safety metrics:
- Unauthorized-write rate must remain zero.
- Unauthorized tool/delegation rate must remain zero.
- Review/rollback procedure: Review this draft on or before 2026-10-18. To
pause or change runtime behavior, use the existing capability/tool
configuration and human-approved change process. This draft document has no
runtime rollback effect.

View File

@ -726,13 +726,19 @@ async def trigger_build(p: dict) -> dict:
# Ollama helpers — direkte mot emma-gpu-vm
# ---------------------------------------------------------------------------
async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict:
async def _ollama_chat(model: str, prompt: str, system: str = "", history: Optional[List[Dict[str, str]]] = None) -> dict:
# ... (beholdt uendret)
messages = []
if system:
messages.append({"role": "system", "content": system})
if history:
messages.extend(history)
messages.append({"role": "user", "content": prompt})
payload = { "model": model, "messages": messages, "stream": False }
payload = {
"model": model,
"messages": messages,
"stream": False
}
try:
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload)
@ -873,12 +879,38 @@ async def tui_command(p): return await _agent_post("/tui-comman
async def run_jason(p):
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk."""
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
def _normalize_emma_history(value):
if not isinstance(value, list):
return []
normalized = []
for entry in value:
if not isinstance(entry, dict):
continue
role = entry.get("role")
content = entry.get("content")
if role not in {"user", "assistant"}:
continue
if not isinstance(content, str):
continue
content = content.strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized[-40:]
async def run_emma(p: dict) -> dict:
"""Kaller den kanoniske Emma-agenten med en prompt."""
capability_system_context = build_capability_system_context()
return await canonical_emma.run(
prompt=p.get("prompt", p.get("message", "")),
history=[],
history=_normalize_emma_history(p.get("history")),
system_context=capability_system_context,
)
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
@ -1164,7 +1196,7 @@ TOOLS = {
),
# AI Agents
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
"run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}),
"run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type": "object", "properties": {"prompt": {"type": "string"}, "history": {"type": "array", "items": {"type": "object", "properties": {"role": {"type": "string", "enum": ["user", "assistant"]}, "content": {"type": "string"}}}}}}),
# Local models — read-only discovery
"list_emma_models": (

View File

@ -67,8 +67,24 @@ router.get('/approvals', (req, res) => {
});
});
function normalizeHistory(value) {
if (!Array.isArray(value)) return [];
return value
.filter(
(entry) =>
entry &&
typeof entry === 'object' &&
(entry.role === 'user' || entry.role === 'assistant') &&
typeof entry.content === 'string' &&
entry.content.trim().length > 0
)
.slice(-40)
.map(({ role, content }) => ({ role, content: content.trim() }));
}
router.post('/emma/chat', async (req, res) => {
const { message, session_id, ticket_number } = req.body;
const { message, session_id, ticket_number, history } = req.body;
if (!message || typeof message !== 'string' || message.length > 4096) {
return res.status(400).json({ status: 'error', message: 'Invalid message.'});
@ -79,7 +95,10 @@ router.post('/emma/chat', async (req, res) => {
}
try {
const emmaData = await callAgent('run_emma', { prompt: message, history: [] });
const emmaData = await callAgent('run_emma', {
prompt: message,
history: normalizeHistory(history),
});
res.json({
status: 'success',

View File

@ -8,30 +8,32 @@ interface User {
function App() {
const [user, setUser] = useState<User | null>(null);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
fetch('/auth/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then(setUser);
.then(setUser)
.finally(() => setAuthChecked(true));
}, []);
if (!authChecked) {
return null; // or a loading spinner
}
if (!user) {
return (
<main className="login">
<div className="login">
<h1>OPAX</h1>
<a href="/auth/google">Continue with Google</a>
</main>
</div>
);
}
return (
<main>
<header>
<b>OPAX / Operations Console</b>
<span>{user.email}</span>
</header>
<OperationsConsole />
</main>
<div className="emma-workspace">
<OperationsConsole user={user} />
</div>
);
}

View File

@ -1,105 +1,160 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
interface Message {
role: string;
content: string;
export interface Message {
id: string;
role: 'user' | 'assistant' | 'status';
content: string;
isError?: boolean;
retryPayload?: Message;
}
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);
interface ChatWorkspaceProps {
messages: Message[];
isLoading: boolean;
onSendMessage: (message: string) => void;
onRetry: (originalMessage: Message, errorStatusId: string) => void;
composerValue: string;
setComposerValue: (value: string) => void;
composerRef: React.RefObject<HTMLTextAreaElement>;
}
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 StarterPrompts = ({ onSelect }: { onSelect: (prompt: string) => void }) => {
const prompts = [
'Gi meg en kort status på det viktigste jeg bør følge opp.',
'Hjelp meg å strukturere neste arbeidsøkt.',
'Oppsummer risikoer og neste beslutninger.',
'Lag et utkast til en konkret oppfølgingsplan.',
];
const handleSend = async () => {
if (!message.trim()) return;
return (
<div className="empty-state">
<div className="empty-state-mark"></div>
<h1>Hva vil du oversikt over i dag?</h1>
<p>Emma hjelper deg å strukturere arbeid, analysere situasjoner og finne tydelige neste steg.</p>
<div className="starter-prompts">
{prompts.map((p) => (
<button key={p} className="starter-prompt" onClick={() => onSelect(p)}>
{p}
</button>
))}
</div>
</div>
);
};
const newMessage: Message = { role: 'user', content: message };
setTranscript(prev => [...prev, newMessage]);
setMessage('');
setLoading(true);
setError(null);
const PromptComposer = ({ onSend, isLoading, value, setValue, composerRef }: {
onSend: (val: string) => void;
isLoading: boolean;
value: string;
setValue: (val: string) => void;
composerRef: React.RefObject<HTMLTextAreaElement>;
}) => {
useEffect(() => {
if (composerRef.current) {
composerRef.current.style.height = 'auto';
composerRef.current.style.height = `${composerRef.current.scrollHeight}px`;
}
}, [value, composerRef]);
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 }),
});
const handleSend = () => {
if (value.trim()) onSend(value.trim());
};
if (!res.ok) {
throw new Error('Emma is unavailable.');
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
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 gjort?</p>
<div className="quick-prompts">
<button>Status 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>
return (
<div className="composer-container">
<div className="composer-inner">
<div className="composer">
<textarea
ref={composerRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Spør Emma om status, analyse eller neste steg …"
disabled={isLoading}
rows={1}
aria-label="Chat message input"
/>
<button onClick={handleSend} disabled={isLoading || !value.trim()} aria-label="Send message">
</button>
</div>
);
</div>
</div>
);
};
const ChatWorkspace: React.FC<ChatWorkspaceProps> = ({ messages, isLoading, onSendMessage, onRetry, composerValue, setComposerValue, composerRef }) => {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isLoading]);
const handleStarterSelect = (prompt: string) => {
setComposerValue(prompt);
setTimeout(() => {
if (composerRef.current) {
composerRef.current.focus();
composerRef.current.setSelectionRange(prompt.length, prompt.length);
}
}, 0);
};
return (
<div className="chat-workspace">
<div className="transcript" ref={scrollRef}>
<div className="transcript-inner">
{messages.length === 0 && !isLoading ? (
<StarterPrompts onSelect={handleStarterSelect} />
) : (
messages.map((msg) => {
if (msg.role === 'status' && msg.isError) {
return (
<div key={msg.id} className="message assistant">
<div className="error-message" role="alert">
<p>{msg.content}</p>
{msg.retryPayload && <button onClick={() => onRetry(msg.retryPayload!, msg.id)}>Prøv igjen</button>}
</div>
</div>
);
}
return (
<div key={msg.id} className={`message ${msg.role}`}>
<div className="message-content">
<div className="message-role" aria-hidden="true">{msg.role}</div>
<p>{msg.content}</p>
</div>
</div>
);
})
)}
{isLoading && (
<div className="message assistant">
<div className="message-content">
<div className="loading-indicator" aria-live="polite">Emma tenker </div>
</div>
</div>
)}
</div>
</div>
<PromptComposer
onSend={onSendMessage}
isLoading={isLoading}
value={composerValue}
setValue={setComposerValue}
composerRef={composerRef}
/>
</div>
);
};
export default ChatWorkspace;

View File

@ -1,24 +1,64 @@
import React from 'react';
const Sidebar = () => {
interface User {
email: string;
}
interface Conversation {
id: string;
title: string;
}
interface SidebarProps {
user: User | null;
conversations: Conversation[];
activeConversationId: string | null;
onNewConversation: () => void;
onSelectConversation: (id: string) => void;
isSidebarOpen: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({
user,
conversations,
activeConversationId,
onNewConversation,
onSelectConversation,
isSidebarOpen,
}) => {
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 id="sidebar" className={`sidebar ${isSidebarOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<div className="logo">OPAX</div>
<div className="sub-logo">Emma workspace</div>
</div>
<div className="profile">User Profile</div>
<button className="new-conversation-button" onClick={onNewConversation}>
Ny samtale
</button>
<div className="sidebar-divider"></div>
<div className="session-heading">Denne økten</div>
<div className="conversation-list">
{conversations.map((conv) => (
<button
key={conv.id}
className={`conversation-item ${conv.id === activeConversationId ? 'active' : ''}`}
onClick={() => onSelectConversation(conv.id)}
title={conv.title}
aria-current={conv.id === activeConversationId ? 'page' : undefined}
>
{conv.title}
</button>
))}
</div>
{user && (
<div className="sidebar-footer" title={user.email}>
{user.email}
</div>
)}
</div>
);
};

View File

@ -1,15 +1,158 @@
import React from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import Sidebar from '../components/Sidebar';
import ChatWorkspace from '../components/ChatWorkspace';
import ContextPanel from '../components/ContextPanel';
import ChatWorkspace, { Message } from '../components/ChatWorkspace';
const generateUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
interface User {
email: string;
}
interface Conversation {
id: string;
title: string;
messages: Message[];
}
const OperationsConsole = ({ user }: { user: User | null }) => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [composerValue, setComposerValue] = useState('');
const composerRef = useRef<HTMLTextAreaElement>(null);
const executeEmmaRequest = useCallback(async (prompt: string, baseMessages: Message[], historyMessages: Message[]) => {
if (!activeConversationId) return;
setIsLoading(true);
const history = historyMessages
.filter(
(message) =>
(message.role === 'user' || message.role === 'assistant') &&
message.content.trim().length > 0
)
.slice(-40)
.map(({ role, content }) => ({ role, content }));
try {
const res = await fetch('/api/console/emma/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message: prompt, history }),
});
if (!res.ok) throw new Error('Network response was not ok');
const data = await res.json();
if (data.status !== 'success' || typeof data.reply !== 'string') {
throw new Error(data.message || 'Failed to get a valid reply from Emma.');
}
const emmaMessage: Message = { id: generateUUID(), role: 'assistant', content: data.reply };
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: [...baseMessages, emmaMessage] } : c));
} catch (e) {
const errorStatus: Message = {
id: generateUUID(),
role: 'status',
content: 'Noe gikk galt. Kunne ikke hente svar fra Emma.',
isError: true,
retryPayload: { id: generateUUID(), role: 'user', content: prompt }, // Self-contained retry info
};
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: [...baseMessages, errorStatus] } : c));
} finally {
setIsLoading(false);
}
}, [activeConversationId]);
const handleSendMessage = async (messageContent: string) => {
if (!activeConversationId || !messageContent.trim()) return;
setComposerValue('');
const activeConvo = conversations.find(c => c.id === activeConversationId);
if (!activeConvo) return;
const userMessage: Message = { id: generateUUID(), role: 'user', content: messageContent };
const newBaseMessages = [...activeConvo.messages, userMessage];
// Optimistically update title
if (activeConvo.messages.length === 0) {
const newTitle = messageContent.substring(0, 40) + (messageContent.length > 40 ? '...' : '');
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, title: newTitle, messages: newBaseMessages } : c));
} else {
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: newBaseMessages } : c));
}
await executeEmmaRequest(messageContent, newBaseMessages, activeConvo.messages);
};
const handleRetry = (originalMessage: Message, errorStatusId: string) => {
if (!activeConversationId) return;
const activeConvo = conversations.find(c => c.id === activeConversationId);
if (!activeConvo) return;
const messagesWithoutError = activeConvo.messages.filter(m => m.id !== errorStatusId);
const historyBeforeRetryPrompt = messagesWithoutError.slice(0, -1);
setConversations(prev => prev.map(c => c.id === activeConversationId ? { ...c, messages: messagesWithoutError } : c));
executeEmmaRequest(originalMessage.content, messagesWithoutError, historyBeforeRetryPrompt);
};
const handleNewConversation = useCallback(() => {
const newConversation: Conversation = { id: generateUUID(), title: 'Ny samtale', messages: [] };
setConversations((prev) => [...prev, newConversation]);
setActiveConversationId(newConversation.id);
if (window.innerWidth <= 768) setSidebarOpen(false);
}, []);
useEffect(() => {
if (conversations.length === 0) handleNewConversation();
}, [conversations.length, handleNewConversation]);
const handleSelectConversation = (id: string) => {
setActiveConversationId(id);
if (window.innerWidth <= 768) setSidebarOpen(false);
};
const activeConversation = conversations.find(c => c.id === activeConversationId);
const OperationsConsole = () => {
return (
<div className="console-layout">
<Sidebar />
<ChatWorkspace />
<ContextPanel />
</div>
<>
<button
className="mobile-menu-button"
onClick={() => setSidebarOpen(!isSidebarOpen)}
aria-label={isSidebarOpen ? "Lukk meny" : "Åpne meny"}
aria-controls="sidebar"
aria-expanded={isSidebarOpen}
>
</button>
<Sidebar
user={user}
conversations={conversations}
activeConversationId={activeConversationId}
onNewConversation={handleNewConversation}
onSelectConversation={handleSelectConversation}
isSidebarOpen={isSidebarOpen}
/>
<main className="main-content">
<ChatWorkspace
messages={activeConversation?.messages || []}
isLoading={isLoading}
onSendMessage={handleSendMessage}
onRetry={handleRetry}
composerValue={composerValue}
setComposerValue={setComposerValue}
composerRef={composerRef}
/>
</main>
</>
);
};

View File

@ -1,21 +1,33 @@
:root {
--font-family: monospace;
--background-color: #0d1117;
--text-color: #e6edf3;
--font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--background-color: #111118; /* Deep graphite */
--sidebar-background: #191921;
--composer-background: #20212c;
--message-user-background: #2a2a36;
--text-color: #e1e1e6;
--text-color-secondary: #8b949e;
--text-color-placeholder: #6a6a79;
--border-color: #30363d;
--container-background: #161b22;
--button-background: #21262d;
--button-background-hover: #30363d;
--button-primary-background: #238636;
--button-primary-background-hover: #2ea043;
--accent-color: #00a8cc; /* Restrained cyan/teal */
--accent-color-hover: #00c0e8;
--error-color: #f85149;
--spacing-unit: 16px;
--sidebar-width: 260px;
--reading-width: 760px;
--border-radius: 8px;
--composer-border-radius: 20px;
}
*,
*::before,
*::after {
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
*, *::before, *::after {
box-sizing: border-box;
}
@ -24,131 +36,10 @@ body {
background-color: var(--background-color);
color: var(--text-color);
font-family: var(--font-family);
font-size: 14px;
}
main {
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: var(--spacing-unit);
background-color: var(--container-background);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
}
header span {
color: var(--text-color-secondary);
}
.content-wrapper {
padding: calc(var(--spacing-unit) * 2);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.page-header {
margin-bottom: calc(var(--spacing-unit) * 2);
text-align: center;
}
.page-header h1 {
font-size: 24px;
margin-bottom: calc(var(--spacing-unit) / 2);
}
.page-header p {
font-size: 16px;
color: var(--text-color-secondary);
max-width: 600px;
margin: 0 auto;
}
.actions-container {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--spacing-unit) * 2);
}
@media (min-width: 768px) {
.actions-container {
grid-template-columns: 1fr 1fr;
}
}
.action-card {
background-color: var(--container-background);
border: 1px solid var(--border-color);
padding: var(--spacing-unit);
border-radius: 6px;
}
.action-card h2 {
margin-top: 0;
font-size: 18px;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-unit);
margin-bottom: var(--spacing-unit);
}
button {
width: 100%;
background: var(--button-background);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 10px var(--spacing-unit);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: var(--button-background-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.primary {
background-color: var(--button-primary-background);
border-color: var(--button-primary-background-hover);
}
button.primary:hover:not(:disabled) {
background-color: var(--button-primary-background-hover);
}
.result-display {
margin-top: var(--spacing-unit);
padding: var(--spacing-unit);
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
min-height: 100px;
}
.result-display strong {
color: var(--text-color-secondary);
}
.error {
color: var(--error-color);
margin-top: calc(var(--spacing-unit) / 2);
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login {
@ -159,7 +50,362 @@ button.primary:hover:not(:disabled) {
text-align: center;
}
.login h1 {
font-size: 32px;
font-weight: 600;
}
.login a {
color: #58a6ff;
font-size: 18px;
background: var(--accent-color);
color: white;
padding: 12px 24px;
border-radius: var(--border-radius);
text-decoration: none;
transition: background-color 0.2s ease;
}
.login a:hover {
background: var(--accent-color-hover);
}
.emma-workspace {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
.sidebar {
background: var(--sidebar-background);
width: var(--sidebar-width);
height: 100%;
display: flex;
flex-direction: column;
padding: var(--spacing-unit);
border-right: 1px solid var(--border-color);
transition: transform 0.3s ease;
flex-shrink: 0;
}
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.mobile-menu-button {
display: none;
position: absolute;
top: var(--spacing-unit);
left: var(--spacing-unit);
z-index: 100;
background: var(--composer-background);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 40px;
height: 40px;
font-size: 24px;
}
@media (max-width: 768px) {
.sidebar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 99;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.mobile-menu-button {
display: block;
}
}
.sidebar-header {
padding-bottom: var(--spacing-unit);
}
.sidebar-header .logo {
font-size: 20px;
font-weight: 600;
}
.sidebar-header .sub-logo {
font-size: 14px;
color: var(--text-color-secondary);
}
.new-conversation-button {
background: var(--accent-color);
color: white;
width: 100%;
border: none;
padding: 10px;
border-radius: var(--border-radius);
font-size: 15px;
cursor: pointer;
margin: var(--spacing-unit) 0;
transition: background-color 0.2s ease;
text-align: center;
}
.new-conversation-button:hover {
background: var(--accent-color-hover);
}
.sidebar-divider {
border-bottom: 1px solid var(--border-color);
margin: var(--spacing-unit) 0;
}
.session-heading {
font-size: 12px;
font-weight: 500;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0 8px;
margin-bottom: 8px;
}
.conversation-list {
flex-grow: 1;
overflow-y: auto;
}
.conversation-item {
padding: 8px 12px;
border-radius: var(--border-radius);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color 0.2s ease;
}
.conversation-item:hover {
background-color: var(--composer-background);
}
.conversation-item.active {
background-color: var(--accent-color);
color: white;
}
.sidebar-footer {
padding-top: var(--spacing-unit);
border-top: 1px solid var(--border-color);
font-size: 13px;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ChatWorkspace Styles */
.chat-workspace {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.transcript {
flex-grow: 1;
overflow-y: auto;
padding: 0 var(--spacing-unit);
}
.transcript-inner {
max-width: var(--reading-width);
margin: 0 auto;
padding: calc(var(--spacing-unit) * 2) 0;
}
.empty-state {
text-align: center;
padding-top: 10vh;
}
.empty-state-mark {
font-size: 32px; /* Replace with a real CSS mark if needed */
color: var(--accent-color);
margin-bottom: var(--spacing-unit);
}
.empty-state h1 {
font-size: 32px;
font-weight: 600;
margin: 0 0 calc(var(--spacing-unit) / 2);
}
.empty-state p {
color: var(--text-color-secondary);
max-width: 450px;
margin: 0 auto calc(var(--spacing-unit) * 2);
}
.starter-prompts {
display: flex;
justify-content: center;
gap: var(--spacing-unit);
flex-wrap: wrap;
max-width: 600px;
margin: 0 auto;
}
.starter-prompt {
background: var(--composer-background);
border: 1px solid var(--border-color);
padding: 10px 14px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease;
text-align: left;
color: var(--text-color);
}
.starter-prompt:hover {
background: var(--message-user-background);
border-color: var(--accent-color);
}
.message {
margin-bottom: calc(var(--spacing-unit) * 1.5);
display: flex;
flex-direction: column;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message-content {
padding: 12px 16px;
border-radius: var(--border-radius);
max-width: 90%;
}
.message.user .message-content {
background: var(--message-user-background);
}
.message.assistant .message-content {
background: transparent;
}
.message-content p {
margin: 0;
}
.message-role {
font-size: 12px;
font-weight: 600;
color: var(--text-color-secondary);
margin-bottom: 4px;
text-transform: uppercase;
}
.loading-indicator, .error-message {
color: var(--text-color-secondary);
font-style: italic;
}
.error-message button {
width: auto;
padding: 4px 8px;
margin-left: 8px;
font-size: 13px;
border: 1px solid var(--accent-color);
background: transparent;
color: var(--accent-color);
}
.error-message button:hover {
background: var(--accent-color);
color: white;
}
/* Composer Styles */
.composer-container {
padding: var(--spacing-unit) 0;
width: 100%;
flex-shrink: 0;
}
.composer-inner {
max-width: var(--reading-width);
margin: 0 auto;
padding: 0 var(--spacing-unit);
}
.composer {
background: var(--composer-background);
border: 1px solid var(--border-color);
border-radius: var(--composer-border-radius);
padding: 8px 8px 8px 20px;
display: flex;
align-items: flex-end;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: border-color 0.2s ease;
}
.composer:focus-within {
border-color: var(--accent-color);
}
.composer textarea {
flex-grow: 1;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--text-color);
font-family: inherit;
font-size: 16px;
line-height: 1.5;
padding: 8px 0;
max-height: 200px;
}
.composer textarea::placeholder {
color: var(--text-color-placeholder);
}
.composer button {
background: var(--accent-color);
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
flex-shrink: 0;
margin-left: 8px;
display: grid;
place-items: center;
transition: background-color 0.2s ease;
}
.composer button:hover:not(:disabled) {
background-color: var(--accent-color-hover);
}
.composer button:disabled {
background-color: var(--border-color);
cursor: not-allowed;
opacity: 1;
}

View File

@ -1 +1 @@
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/api':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});

24
scripts/check-live-services.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ID="propane-will-491900-m5"
REGION="us-central1"
for SERVICE in opax-mcp opax-web; do
echo
echo "==> ${SERVICE}"
gcloud run services describe "${SERVICE}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--format="table(
metadata.name,
status.url,
status.latestReadyRevisionName,
status.traffic[0].revisionName,
spec.template.spec.containers[0].image
)"
done
echo
echo "Operator URL: https://opax.vauco.no"

104
scripts/deploy-mcp.sh Executable file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
PROJECT_ID="propane-will-491900-m5"
REGION="us-central1"
REPOSITORY="us-central1-docker.pkg.dev/${PROJECT_ID}/osvauco-repo"
MCP_SERVICE="opax-mcp"
MCP_IMAGE="${REPOSITORY}/opax-mcp"
echo "==> Submitting MCP Cloud Build"
BUILD_ID="$(
gcloud builds submit \
--async \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--config="cloudbuild.deploy.yaml" \
--format="value(id)" \
.
)"
if [[ -z "${BUILD_ID}" ]]; then
echo "ERROR: Cloud Build did not return a build ID." >&2
exit 1
fi
echo "==> Cloud Build ID: ${BUILD_ID}"
echo "==> Streaming Cloud Build logs"
set +e
gcloud builds log "${BUILD_ID}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--stream
BUILD_LOG_EXIT_CODE=$?
set -e
BUILD_STATUS="$(
gcloud builds describe "${BUILD_ID}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--format="value(status)"
)"
IMAGE_REF="$(
gcloud builds describe "${BUILD_ID}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--format="value(results.images[0].name)"
)"
if [[ -z "${IMAGE_REF}" ]]; then
IMAGE_REF="${MCP_IMAGE}:${BUILD_ID}"
fi
if [[ "${IMAGE_REF}" == *@sha256:* ]]; then
DEPLOY_IMAGE="${IMAGE_REF}"
else
IMAGE_DIGEST="$(
gcloud artifacts docker images describe "${IMAGE_REF}" \
--project="${PROJECT_ID}" \
--format="value(image_summary.digest)"
)"
if [[ -z "${IMAGE_DIGEST}" ]]; then
echo "ERROR: No pushed MCP image digest was found." >&2
echo "Cloud Build status: ${BUILD_STATUS}" >&2
echo "Cloud Build log exit code: ${BUILD_LOG_EXIT_CODE}" >&2
exit 1
fi
DEPLOY_IMAGE="${MCP_IMAGE}@${IMAGE_DIGEST}"
fi
echo "==> Cloud Build status: ${BUILD_STATUS}"
echo "==> Deploying immutable MCP image:"
echo "${DEPLOY_IMAGE}"
gcloud run deploy "${MCP_SERVICE}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--image="${DEPLOY_IMAGE}" \
--quiet
echo
echo "==> Active MCP deployment"
gcloud run services describe "${MCP_SERVICE}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--format="table(
metadata.name,
status.url,
status.latestReadyRevisionName,
status.traffic[0].revisionName,
spec.template.spec.containers[0].image
)"
echo
echo "MCP deployment complete."
echo "Cloud Build log exit code: ${BUILD_LOG_EXIT_CODE}"

60
scripts/deploy-opax-web.sh Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
PROJECT_ID="propane-will-491900-m5"
REGION="us-central1"
REPOSITORY="us-central1-docker.pkg.dev/${PROJECT_ID}/osvauco-repo"
WEB_SERVICE="opax-web"
WEB_IMAGE="${REPOSITORY}/opax-web"
TAG="manual-$(date -u +%Y%m%d-%H%M%S)"
echo "==> Building OPAX Web image:"
echo "${WEB_IMAGE}:${TAG}"
gcloud builds submit opax-web \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--tag="${WEB_IMAGE}:${TAG}"
WEB_DIGEST="$(
gcloud artifacts docker images describe "${WEB_IMAGE}:${TAG}" \
--project="${PROJECT_ID}" \
--format="value(image_summary.digest)"
)"
if [[ -z "${WEB_DIGEST}" ]]; then
echo "ERROR: No immutable OPAX Web image digest was found." >&2
exit 1
fi
DEPLOY_IMAGE="${WEB_IMAGE}@${WEB_DIGEST}"
echo "==> Deploying immutable OPAX Web image:"
echo "${DEPLOY_IMAGE}"
gcloud run deploy "${WEB_SERVICE}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--image="${DEPLOY_IMAGE}" \
--quiet
echo
echo "==> Active OPAX Web deployment"
gcloud run services describe "${WEB_SERVICE}" \
--project="${PROJECT_ID}" \
--region="${REGION}" \
--format="table(
metadata.name,
status.url,
status.latestReadyRevisionName,
status.traffic[0].revisionName,
spec.template.spec.containers[0].image
)"
echo
echo "Live operator URL: https://opax.vauco.no"
echo "Hard refresh after deployment: Ctrl+Shift+R"